Skip to content

Commit 7842e14

Browse files
authored
Merge pull request #91 from Threadlines/feature/sidebar-polish
Inbox sidebar, browser auto-open, and eleven rounds of polish
2 parents 37faa2e + 84b5967 commit 7842e14

36 files changed

Lines changed: 5154 additions & 5472 deletions

apps/web/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"@effect/atom-react": "catalog:",
2222
"@fontsource-variable/cascadia-mono": "^5.3.0",
2323
"@fontsource-variable/schibsted-grotesk": "^5.3.0",
24-
"@formkit/auto-animate": "^0.10.0",
2524
"@legendapp/list": "^3.3.3",
2625
"@lexical/react": "^0.47.0",
2726
"@pierre/diffs": "catalog:",

apps/web/src/browserPanelStore.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it } from "vite-plus/test";
22

3-
import { makeBrowserTab, nextActiveTabId, steppedZoom, type BrowserTab } from "./browserPanelStore";
3+
import {
4+
makeBrowserTab,
5+
nextActiveTabId,
6+
steppedZoom,
7+
waitForPreviewWebview,
8+
type BrowserTab,
9+
} from "./browserPanelStore";
410

511
function tabs(count: number): BrowserTab[] {
612
return Array.from({ length: count }, () => makeBrowserTab());
@@ -49,3 +55,48 @@ describe("steppedZoom", () => {
4955
expect(steppedZoom(1.05, -1)).toBe(1);
5056
});
5157
});
58+
59+
describe("waitForPreviewWebview", () => {
60+
it("returns the page as soon as one registers, without waiting out the cap", async () => {
61+
let element: string | null = null;
62+
const listeners = new Set<() => void>();
63+
64+
const waited = waitForPreviewWebview<string>({
65+
resolve: () => element,
66+
subscribe: (listener) => {
67+
listeners.add(listener);
68+
return () => listeners.delete(listener);
69+
},
70+
timeoutMs: 5_000,
71+
});
72+
73+
// The panel mounts its element a tick after the agent asked for it.
74+
element = "webview";
75+
for (const listener of listeners) listener();
76+
77+
expect(await waited).toBe("webview");
78+
// The subscription is not left behind once it has served its purpose.
79+
expect(listeners.size).toBe(0);
80+
});
81+
82+
it("gives up at the cap so a panel that never mounts still answers", async () => {
83+
const timers: Array<() => void> = [];
84+
85+
const waited = waitForPreviewWebview<string>({
86+
resolve: () => null,
87+
subscribe: () => () => undefined,
88+
timeoutMs: 5_000,
89+
setTimeoutFn: ((callback: () => void) => {
90+
timers.push(callback);
91+
return 1 as unknown as ReturnType<typeof setTimeout>;
92+
}) as unknown as typeof globalThis.setTimeout,
93+
clearTimeoutFn: (() => undefined) as unknown as typeof globalThis.clearTimeout,
94+
});
95+
96+
timers[0]?.();
97+
98+
// Null rather than a rejection: the caller reports "no page" the way it
99+
// always has instead of the agent seeing a thrown error.
100+
expect(await waited).toBeNull();
101+
});
102+
});

apps/web/src/browserPanelStore.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,143 @@ export function nextActiveTabId(
157157
return (remaining[closedIndex] ?? remaining[remaining.length - 1])!.id;
158158
}
159159

160+
/**
161+
* The live `<webview>` elements, by thread and tab.
162+
*
163+
* Deliberately not in the store's state: these are DOM nodes, they change on
164+
* every tab mount, and nothing renders from them -- the automation host is the
165+
* only caller, and it wants the current element rather than a re-render. Kept
166+
* here rather than in the panel because the host now outlives the panel.
167+
*/
168+
export interface PreviewWebviewHandle {
169+
getWebContentsId: () => number;
170+
loadURL: (url: string) => Promise<void>;
171+
getBoundingClientRect: () => DOMRect;
172+
}
173+
174+
const webviewRegistry = new Map<string, PreviewWebviewHandle>();
175+
const webviewListeners = new Set<() => void>();
176+
177+
function webviewKey(threadRef: ScopedThreadRef, tabId: string): string {
178+
return `${scopedThreadKey(threadRef)}::${tabId}`;
179+
}
180+
181+
export function registerPreviewWebview(
182+
threadRef: ScopedThreadRef,
183+
tabId: string,
184+
element: PreviewWebviewHandle | null,
185+
): void {
186+
const key = webviewKey(threadRef, tabId);
187+
if (element === null) {
188+
webviewRegistry.delete(key);
189+
} else {
190+
webviewRegistry.set(key, element);
191+
}
192+
for (const listener of webviewListeners) {
193+
listener();
194+
}
195+
}
196+
197+
export function getPreviewWebview(
198+
threadRef: ScopedThreadRef,
199+
tabId: string,
200+
): PreviewWebviewHandle | null {
201+
return webviewRegistry.get(webviewKey(threadRef, tabId)) ?? null;
202+
}
203+
204+
export function subscribePreviewWebviews(listener: () => void): () => void {
205+
webviewListeners.add(listener);
206+
return () => {
207+
webviewListeners.delete(listener);
208+
};
209+
}
210+
211+
/** Test seam: the registry outlives any one component, so tests clear it. */
212+
export function resetPreviewWebviewsForTests(): void {
213+
webviewRegistry.clear();
214+
webviewListeners.clear();
215+
}
216+
217+
/**
218+
* Waits for a page to exist, for as long as it is worth waiting.
219+
*
220+
* Opening the panel is not instant: the element mounts, attaches, and only then
221+
* has a webContents to drive. The agent's call arrives before all of that, so
222+
* it waits here rather than being told there is no browser -- but it waits
223+
* against a cap well under the broker's own timeout, so a panel that never
224+
* comes up answers the agent instead of hanging it.
225+
*/
226+
export async function waitForPreviewWebview<T>(input: {
227+
readonly resolve: () => T | null;
228+
readonly subscribe: (listener: () => void) => () => void;
229+
readonly timeoutMs: number;
230+
readonly setTimeoutFn?: typeof globalThis.setTimeout;
231+
readonly clearTimeoutFn?: typeof globalThis.clearTimeout;
232+
}): Promise<T | null> {
233+
const immediate = input.resolve();
234+
if (immediate !== null) {
235+
return immediate;
236+
}
237+
const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout;
238+
const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout;
239+
240+
return await new Promise<T | null>((resolvePromise) => {
241+
let settled = false;
242+
const finish = (value: T | null) => {
243+
if (settled) return;
244+
settled = true;
245+
clearTimeoutFn(timer);
246+
unsubscribe();
247+
resolvePromise(value);
248+
};
249+
const unsubscribe = input.subscribe(() => {
250+
const candidate = input.resolve();
251+
if (candidate !== null) {
252+
finish(candidate);
253+
}
254+
});
255+
const timer = setTimeoutFn(() => finish(null), input.timeoutMs);
256+
// Racing the subscription: registration may have landed between the first
257+
// check and the listener going on.
258+
const late = input.resolve();
259+
if (late !== null) {
260+
finish(late);
261+
}
262+
});
263+
}
264+
265+
/** How long an agent's call waits for the panel it just opened to have a page. */
266+
export const PREVIEW_WEBVIEW_WAIT_MS = 5_000;
267+
268+
/**
269+
* What the agent is doing in a thread's browser.
270+
*
271+
* In the store rather than in the panel because the host that produces it now
272+
* outlives the panel that draws it: an agent can act on a page while the panel
273+
* is closed, and when it opens the panel has to already know where the pointer
274+
* went and which tab is the agent's.
275+
*/
276+
export interface ThreadBrowserAgentState {
277+
/** The tab the agent pinned itself to; null until it acts. */
278+
tabId: string | null;
279+
point: { x: number; y: number; from?: { x: number; y: number }; sequence: number } | null;
280+
activity: {
281+
phase: "running" | "done";
282+
verb: string;
283+
detail: string | null;
284+
sequence: number;
285+
} | null;
286+
}
287+
288+
export const EMPTY_AGENT_STATE: ThreadBrowserAgentState = Object.freeze({
289+
tabId: null,
290+
point: null,
291+
activity: null,
292+
});
293+
160294
interface BrowserPanelStoreState {
161295
browserStateByThreadKey: Record<string, ThreadBrowserState>;
296+
agentStateByThreadKey: Record<string, ThreadBrowserAgentState>;
162297
splitChatFraction: number;
163298
/** Hides the chat so the page gets the whole centre; the split is remembered. */
164299
expanded: boolean;
@@ -188,6 +323,15 @@ interface BrowserPanelStoreState {
188323
setTabZoom: (threadRef: ScopedThreadRef, tabId: string, zoomFactor: number) => void;
189324
toggleDeviceToolbar: () => void;
190325
setAppearance: (appearance: BrowserAppearance) => void;
326+
setAgentTab: (threadRef: ScopedThreadRef, tabId: string | null) => void;
327+
setAgentPoint: (
328+
threadRef: ScopedThreadRef,
329+
point: { x: number; y: number; from?: { x: number; y: number } } | null,
330+
) => void;
331+
setAgentActivity: (
332+
threadRef: ScopedThreadRef,
333+
activity: ThreadBrowserAgentState["activity"],
334+
) => void;
191335
setSplitChatFraction: (fraction: number) => void;
192336
toggleExpanded: () => void;
193337
}
@@ -204,6 +348,16 @@ function updateThread(
204348
};
205349
}
206350

351+
function updateAgentState(
352+
state: BrowserPanelStoreState,
353+
threadRef: ScopedThreadRef,
354+
update: (current: ThreadBrowserAgentState) => ThreadBrowserAgentState,
355+
): Pick<BrowserPanelStoreState, "agentStateByThreadKey"> {
356+
const key = scopedThreadKey(threadRef);
357+
const current = state.agentStateByThreadKey[key] ?? EMPTY_AGENT_STATE;
358+
return { agentStateByThreadKey: { ...state.agentStateByThreadKey, [key]: update(current) } };
359+
}
360+
207361
function updateTab(
208362
current: ThreadBrowserState,
209363
tabId: string,
@@ -219,6 +373,7 @@ export const useBrowserPanelStore = create<BrowserPanelStoreState>()(
219373
persist(
220374
(set) => ({
221375
browserStateByThreadKey: {},
376+
agentStateByThreadKey: {},
222377
splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION,
223378
expanded: false,
224379
deviceToolbarOpen: false,
@@ -281,6 +436,18 @@ export const useBrowserPanelStore = create<BrowserPanelStoreState>()(
281436
),
282437
toggleDeviceToolbar: () => set((state) => ({ deviceToolbarOpen: !state.deviceToolbarOpen })),
283438
setAppearance: (appearance) => set(() => ({ appearance })),
439+
setAgentTab: (threadRef, tabId) =>
440+
set((state) => updateAgentState(state, threadRef, (current) => ({ ...current, tabId }))),
441+
setAgentPoint: (threadRef, point) =>
442+
set((state) =>
443+
updateAgentState(state, threadRef, (current) => ({
444+
...current,
445+
point:
446+
point === null ? null : { ...point, sequence: (current.point?.sequence ?? 0) + 1 },
447+
})),
448+
),
449+
setAgentActivity: (threadRef, activity) =>
450+
set((state) => updateAgentState(state, threadRef, (current) => ({ ...current, activity }))),
284451
setSplitChatFraction: (fraction) =>
285452
set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })),
286453
toggleExpanded: () => set((state) => ({ expanded: !state.expanded })),
@@ -316,6 +483,16 @@ export function selectThreadBrowserState(
316483
return stored === undefined || stored.tabs.length === 0 ? DEFAULT_THREAD_STATE : stored;
317484
}
318485

486+
export function selectThreadAgentState(
487+
agentStateByThreadKey: Record<string, ThreadBrowserAgentState>,
488+
threadRef: ScopedThreadRef | null,
489+
): ThreadBrowserAgentState {
490+
if (threadRef === null) {
491+
return EMPTY_AGENT_STATE;
492+
}
493+
return agentStateByThreadKey[scopedThreadKey(threadRef)] ?? EMPTY_AGENT_STATE;
494+
}
495+
319496
export function selectActiveTab(state: ThreadBrowserState): BrowserTab | null {
320497
return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0] ?? null;
321498
}

apps/web/src/components/AppSidebarLayout.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
2525
const THREAD_SIDEBAR_DEFAULT_WIDTH = `${THREAD_SIDEBAR_MIN_WIDTH}px`;
2626
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
2727
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "88px";
28-
// Collapsed width of the deck rail. Wide enough for a 32px hit target plus the
29-
// breathing room that keeps status dots off the window edge.
30-
const THREAD_SIDEBAR_RAIL_WIDTH = "2.75rem";
3128

3229
function SidebarControl() {
3330
return (
@@ -50,7 +47,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
5047
const appSettings = useSettings();
5148
const sidebarStyle = {
5249
"--sidebar-width": THREAD_SIDEBAR_DEFAULT_WIDTH,
53-
"--sidebar-width-icon": THREAD_SIDEBAR_RAIL_WIDTH,
5450
...(isElectron && typeof navigator !== "undefined" && isMacPlatform(navigator.platform)
5551
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
5652
: {}),
@@ -139,7 +135,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
139135
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={sidebarStyle}>
140136
<Sidebar
141137
side="left"
142-
collapsible="icon"
138+
// Collapsing hides the sidebar completely: with the inbox gone from the
139+
// pane there is nothing an icon strip could stand in for.
140+
collapsible="offcanvas"
143141
className="border-r border-border bg-rail text-foreground"
144142
resizable={{
145143
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
@@ -149,9 +147,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
149147
}}
150148
>
151149
<ThreadSidebar />
152-
{/* Only the expanded pane resizes; a handle on the fixed-width rail
153-
would advertise a drag that does nothing. */}
154-
<SidebarRail className="group-data-[collapsible=icon]:hidden" />
150+
{/* The drag handle on the pane's edge; it resizes while open. */}
151+
<SidebarRail />
155152
</Sidebar>
156153
{children}
157154
<SidebarControl />

0 commit comments

Comments
 (0)