Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d39c6b8
Sketch the inbox sidebar
badcuban Jul 28, 2026
8594f45
Offer chips beside the dropdown, and answer in plain words
badcuban Jul 28, 2026
9049301
Settle the sketch: chips win, Done keeps its names
badcuban Jul 28, 2026
4462727
Promote Add project out of the overflow
badcuban Jul 28, 2026
ac4ae7b
Lay the inbox's foundations
badcuban Jul 28, 2026
4d0e036
Turn the sidebar into an inbox
badcuban Jul 28, 2026
af8f5e7
Let the inbox breathe, and let it file for itself
badcuban Jul 28, 2026
849e028
Give the rows back their information, and the list its calm
badcuban Jul 28, 2026
3f71047
Polish round four: lifecycle, layout, and the quiet details
badcuban Jul 28, 2026
2cbfc73
Round five: one popup per hover, one rule per section
badcuban Jul 28, 2026
886d9d9
Round six: tie off the thread, and other words that earn their place
badcuban Jul 28, 2026
9345588
Round seven: the header is one control, and the tests learn to see
badcuban Jul 28, 2026
e0aa9f3
Round eight: Wrapped, and the audit earns its keep
badcuban Jul 28, 2026
bc50d88
Rounds nine and ten: the row learns to yield, the chrome learns to stay
badcuban Jul 28, 2026
3356e02
Doublecheck round: trust the diff, not the report
badcuban Jul 28, 2026
5f01ab1
Give wrapped rows the same hover card
badcuban Jul 28, 2026
09a64fc
The card becomes a card, and source control learns to wait its turn
badcuban Jul 28, 2026
b09ae57
Let the browser open itself when an agent reaches for it
badcuban Jul 28, 2026
84b5967
Merge remote-tracking branch 'origin/main' into feature/sidebar-polish
badcuban Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"@effect/atom-react": "catalog:",
"@fontsource-variable/cascadia-mono": "^5.3.0",
"@fontsource-variable/schibsted-grotesk": "^5.3.0",
"@formkit/auto-animate": "^0.10.0",
"@legendapp/list": "^3.3.3",
"@lexical/react": "^0.47.0",
"@pierre/diffs": "catalog:",
Expand Down
53 changes: 52 additions & 1 deletion apps/web/src/browserPanelStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vite-plus/test";

import { makeBrowserTab, nextActiveTabId, steppedZoom, type BrowserTab } from "./browserPanelStore";
import {
makeBrowserTab,
nextActiveTabId,
steppedZoom,
waitForPreviewWebview,
type BrowserTab,
} from "./browserPanelStore";

function tabs(count: number): BrowserTab[] {
return Array.from({ length: count }, () => makeBrowserTab());
Expand Down Expand Up @@ -49,3 +55,48 @@ describe("steppedZoom", () => {
expect(steppedZoom(1.05, -1)).toBe(1);
});
});

describe("waitForPreviewWebview", () => {
it("returns the page as soon as one registers, without waiting out the cap", async () => {
let element: string | null = null;
const listeners = new Set<() => void>();

const waited = waitForPreviewWebview<string>({
resolve: () => element,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
timeoutMs: 5_000,
});

// The panel mounts its element a tick after the agent asked for it.
element = "webview";
for (const listener of listeners) listener();

expect(await waited).toBe("webview");
// The subscription is not left behind once it has served its purpose.
expect(listeners.size).toBe(0);
});

it("gives up at the cap so a panel that never mounts still answers", async () => {
const timers: Array<() => void> = [];

const waited = waitForPreviewWebview<string>({
resolve: () => null,
subscribe: () => () => undefined,
timeoutMs: 5_000,
setTimeoutFn: ((callback: () => void) => {
timers.push(callback);
return 1 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof globalThis.setTimeout,
clearTimeoutFn: (() => undefined) as unknown as typeof globalThis.clearTimeout,
});

timers[0]?.();

// Null rather than a rejection: the caller reports "no page" the way it
// always has instead of the agent seeing a thrown error.
expect(await waited).toBeNull();
});
});
177 changes: 177 additions & 0 deletions apps/web/src/browserPanelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,143 @@ export function nextActiveTabId(
return (remaining[closedIndex] ?? remaining[remaining.length - 1])!.id;
}

/**
* The live `<webview>` elements, by thread and tab.
*
* Deliberately not in the store's state: these are DOM nodes, they change on
* every tab mount, and nothing renders from them -- the automation host is the
* only caller, and it wants the current element rather than a re-render. Kept
* here rather than in the panel because the host now outlives the panel.
*/
export interface PreviewWebviewHandle {
getWebContentsId: () => number;
loadURL: (url: string) => Promise<void>;
getBoundingClientRect: () => DOMRect;
}

const webviewRegistry = new Map<string, PreviewWebviewHandle>();
const webviewListeners = new Set<() => void>();

function webviewKey(threadRef: ScopedThreadRef, tabId: string): string {
return `${scopedThreadKey(threadRef)}::${tabId}`;
}

export function registerPreviewWebview(
threadRef: ScopedThreadRef,
tabId: string,
element: PreviewWebviewHandle | null,
): void {
const key = webviewKey(threadRef, tabId);
if (element === null) {
webviewRegistry.delete(key);
} else {
webviewRegistry.set(key, element);
}
for (const listener of webviewListeners) {
listener();
}
}

export function getPreviewWebview(
threadRef: ScopedThreadRef,
tabId: string,
): PreviewWebviewHandle | null {
return webviewRegistry.get(webviewKey(threadRef, tabId)) ?? null;
}

export function subscribePreviewWebviews(listener: () => void): () => void {
webviewListeners.add(listener);
return () => {
webviewListeners.delete(listener);
};
}

/** Test seam: the registry outlives any one component, so tests clear it. */
export function resetPreviewWebviewsForTests(): void {
webviewRegistry.clear();
webviewListeners.clear();
}

/**
* Waits for a page to exist, for as long as it is worth waiting.
*
* Opening the panel is not instant: the element mounts, attaches, and only then
* has a webContents to drive. The agent's call arrives before all of that, so
* it waits here rather than being told there is no browser -- but it waits
* against a cap well under the broker's own timeout, so a panel that never
* comes up answers the agent instead of hanging it.
*/
export async function waitForPreviewWebview<T>(input: {
readonly resolve: () => T | null;
readonly subscribe: (listener: () => void) => () => void;
readonly timeoutMs: number;
readonly setTimeoutFn?: typeof globalThis.setTimeout;
readonly clearTimeoutFn?: typeof globalThis.clearTimeout;
}): Promise<T | null> {
const immediate = input.resolve();
if (immediate !== null) {
return immediate;
}
const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout;
const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout;

return await new Promise<T | null>((resolvePromise) => {
let settled = false;
const finish = (value: T | null) => {
if (settled) return;
settled = true;
clearTimeoutFn(timer);
unsubscribe();
resolvePromise(value);
};
const unsubscribe = input.subscribe(() => {
const candidate = input.resolve();
if (candidate !== null) {
finish(candidate);
}
});
const timer = setTimeoutFn(() => finish(null), input.timeoutMs);
// Racing the subscription: registration may have landed between the first
// check and the listener going on.
const late = input.resolve();
if (late !== null) {
finish(late);
}
});
}

/** How long an agent's call waits for the panel it just opened to have a page. */
export const PREVIEW_WEBVIEW_WAIT_MS = 5_000;

/**
* What the agent is doing in a thread's browser.
*
* In the store rather than in the panel because the host that produces it now
* outlives the panel that draws it: an agent can act on a page while the panel
* is closed, and when it opens the panel has to already know where the pointer
* went and which tab is the agent's.
*/
export interface ThreadBrowserAgentState {
/** The tab the agent pinned itself to; null until it acts. */
tabId: string | null;
point: { x: number; y: number; from?: { x: number; y: number }; sequence: number } | null;
activity: {
phase: "running" | "done";
verb: string;
detail: string | null;
sequence: number;
} | null;
}

export const EMPTY_AGENT_STATE: ThreadBrowserAgentState = Object.freeze({
tabId: null,
point: null,
activity: null,
});

interface BrowserPanelStoreState {
browserStateByThreadKey: Record<string, ThreadBrowserState>;
agentStateByThreadKey: Record<string, ThreadBrowserAgentState>;
splitChatFraction: number;
/** Hides the chat so the page gets the whole centre; the split is remembered. */
expanded: boolean;
Expand Down Expand Up @@ -188,6 +323,15 @@ interface BrowserPanelStoreState {
setTabZoom: (threadRef: ScopedThreadRef, tabId: string, zoomFactor: number) => void;
toggleDeviceToolbar: () => void;
setAppearance: (appearance: BrowserAppearance) => void;
setAgentTab: (threadRef: ScopedThreadRef, tabId: string | null) => void;
setAgentPoint: (
threadRef: ScopedThreadRef,
point: { x: number; y: number; from?: { x: number; y: number } } | null,
) => void;
setAgentActivity: (
threadRef: ScopedThreadRef,
activity: ThreadBrowserAgentState["activity"],
) => void;
setSplitChatFraction: (fraction: number) => void;
toggleExpanded: () => void;
}
Expand All @@ -204,6 +348,16 @@ function updateThread(
};
}

function updateAgentState(
state: BrowserPanelStoreState,
threadRef: ScopedThreadRef,
update: (current: ThreadBrowserAgentState) => ThreadBrowserAgentState,
): Pick<BrowserPanelStoreState, "agentStateByThreadKey"> {
const key = scopedThreadKey(threadRef);
const current = state.agentStateByThreadKey[key] ?? EMPTY_AGENT_STATE;
return { agentStateByThreadKey: { ...state.agentStateByThreadKey, [key]: update(current) } };
}

function updateTab(
current: ThreadBrowserState,
tabId: string,
Expand All @@ -219,6 +373,7 @@ export const useBrowserPanelStore = create<BrowserPanelStoreState>()(
persist(
(set) => ({
browserStateByThreadKey: {},
agentStateByThreadKey: {},
splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION,
expanded: false,
deviceToolbarOpen: false,
Expand Down Expand Up @@ -281,6 +436,18 @@ export const useBrowserPanelStore = create<BrowserPanelStoreState>()(
),
toggleDeviceToolbar: () => set((state) => ({ deviceToolbarOpen: !state.deviceToolbarOpen })),
setAppearance: (appearance) => set(() => ({ appearance })),
setAgentTab: (threadRef, tabId) =>
set((state) => updateAgentState(state, threadRef, (current) => ({ ...current, tabId }))),
setAgentPoint: (threadRef, point) =>
set((state) =>
updateAgentState(state, threadRef, (current) => ({
...current,
point:
point === null ? null : { ...point, sequence: (current.point?.sequence ?? 0) + 1 },
})),
),
setAgentActivity: (threadRef, activity) =>
set((state) => updateAgentState(state, threadRef, (current) => ({ ...current, activity }))),
setSplitChatFraction: (fraction) =>
set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })),
toggleExpanded: () => set((state) => ({ expanded: !state.expanded })),
Expand Down Expand Up @@ -316,6 +483,16 @@ export function selectThreadBrowserState(
return stored === undefined || stored.tabs.length === 0 ? DEFAULT_THREAD_STATE : stored;
}

export function selectThreadAgentState(
agentStateByThreadKey: Record<string, ThreadBrowserAgentState>,
threadRef: ScopedThreadRef | null,
): ThreadBrowserAgentState {
if (threadRef === null) {
return EMPTY_AGENT_STATE;
}
return agentStateByThreadKey[scopedThreadKey(threadRef)] ?? EMPTY_AGENT_STATE;
}

export function selectActiveTab(state: ThreadBrowserState): BrowserTab | null {
return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0] ?? null;
}
13 changes: 5 additions & 8 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
const THREAD_SIDEBAR_DEFAULT_WIDTH = `${THREAD_SIDEBAR_MIN_WIDTH}px`;
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "88px";
// Collapsed width of the deck rail. Wide enough for a 32px hit target plus the
// breathing room that keeps status dots off the window edge.
const THREAD_SIDEBAR_RAIL_WIDTH = "2.75rem";

function SidebarControl() {
return (
Expand All @@ -50,7 +47,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const appSettings = useSettings();
const sidebarStyle = {
"--sidebar-width": THREAD_SIDEBAR_DEFAULT_WIDTH,
"--sidebar-width-icon": THREAD_SIDEBAR_RAIL_WIDTH,
...(isElectron && typeof navigator !== "undefined" && isMacPlatform(navigator.platform)
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
: {}),
Expand Down Expand Up @@ -139,7 +135,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={sidebarStyle}>
<Sidebar
side="left"
collapsible="icon"
// Collapsing hides the sidebar completely: with the inbox gone from the
// pane there is nothing an icon strip could stand in for.
collapsible="offcanvas"
className="border-r border-border bg-rail text-foreground"
resizable={{
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
Expand All @@ -149,9 +147,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}}
>
<ThreadSidebar />
{/* Only the expanded pane resizes; a handle on the fixed-width rail
would advertise a drag that does nothing. */}
<SidebarRail className="group-data-[collapsible=icon]:hidden" />
{/* The drag handle on the pane's edge; it resizes while open. */}
<SidebarRail />
</Sidebar>
{children}
<SidebarControl />
Expand Down
Loading
Loading