From 0f98bebe9249c08108649b58da89fb385be6856d Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:38:39 -0700 Subject: [PATCH 1/8] feat(web): reorder right panel tabs by dragging (#17730) Backport of upstream 4107930420cffe14776ad80783f7060236b5868b to v0.0.45, so a person can drag the right panel's tabs into the order they want, the overlay's surfaces like any other. It goes at the 0.0.46 refresh, where upstream owns it. Adapted to v0.0.45: its store has no updateThread(state, ...) yet, so moveSurface goes through automaticUpdate, which likewise leaves the user-action revision alone, and the sortable wrapper wraps v0.0.45's tab markup (its audio icon, rename ring and "+" condition stay). The two files the port adds to the overlay join the fork CI's allowlist. (cherry picked from commit 4107930420cffe14776ad80783f7060236b5868b) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lazurio-fork-ci.yml | 5 + apps/web/src/components/ChatView.tsx | 9 + apps/web/src/components/RightPanelTabs.tsx | 338 ++++++++++++-------- apps/web/src/rightPanelStore.test.ts | 23 ++ apps/web/src/rightPanelStore.ts | 28 +- apps/web/src/routes/_chat.pull-requests.tsx | 4 + 6 files changed, 267 insertions(+), 140 deletions(-) diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 35a9c0387d52..c91ff967e446 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -101,6 +101,11 @@ jobs: # DEV-6646). The parser of a view's address is a copy of the server's, whose Environment # browser host answers preview_open of such a view. apps/web/src/lazurio/browserView.ts + # Upstream 410793042 (#17730), backported: the right panel's tabs reorder by dragging. + # It goes at the 0.0.46 refresh, where upstream owns it. Its seams in ChatView.tsx, + # RightPanelTabs.tsx and rightPanelStore.ts are listed above. + apps/web/src/rightPanelStore.test.ts + apps/web/src/routes/_chat.pull-requests.tsx ) unexpected="$(git diff --name-only "$UPSTREAM_SHA..HEAD" | grep -E '^(apps/(web|mobile|desktop)|packages)/' | diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 162ff9c86ab7..7e3cffa2d7d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5189,6 +5189,13 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); + const moveRightPanelSurface = useCallback( + (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }, + [activeThreadRef], + ); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -10308,6 +10315,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={ environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() @@ -10368,6 +10376,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={ environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e65ccb502c66..5861aff4f1d8 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -13,6 +13,17 @@ import type { PullRequestState, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Bot, Smartphone, @@ -28,6 +39,7 @@ import { VolumeOff, } from "lucide-react"; import { + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -110,6 +122,8 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + /** Tabs are draggable only when the owner can persist the new order. */ + onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** @@ -200,6 +214,7 @@ type TabContextMenuAction = | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; +const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; @@ -831,6 +846,61 @@ function PullRequestSurfaceIcon({ return ; } +/** + * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, + * and the order commits on drop. + */ +function SortableTabList(props: { + surfaces: readonly RightPanelSurface[]; + onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + children: ReactNode; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); + const { onMove, surfaces } = props; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const toIndex = surfaces.findIndex((surface) => surface.id === over.id); + if (toIndex >= 0) onMove?.(String(active.id), toIndex); + }, + [onMove, surfaces], + ); + return ( + + + {props.children} + + + ); +} + +function SortableTab({ + surfaceId, + disabled, + className, + ...props +}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: surfaceId, + disabled, + }); + return ( +
+ ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserDefaults = useBrowserDefaults(); @@ -1138,141 +1208,145 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
- {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle( - surface, - props.previewSessions, - props.terminalLabelsById, - props.environmentBrowserTitles, - ); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} + + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + props.environmentBrowserTitles, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - {audio === "muted" ? ( - - ) : ( - - )} - - } + props.onCloseSurface(surface)} + > + - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + {audio === "muted" ? ( + + ) : ( + + )} + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} -
- ); - })} + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); + })} + {props.surfaces.length > 0 ? ( { ), ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); + + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.openBrowser(refA, "tab-a"); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + const surfaceIds = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ); + + store.moveSurface(refA, "browser:tab-a", 0); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]); + store.moveSurface(refA, "browser:tab-a", 2); + expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]); + store.moveSurface(refA, "browser:tab-a", 0); + + store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]); + // Reordering is not a choice about what the panel shows. + expect(store.getUserActionRevision(refA)).toBe(revision); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 39a7b4df07fe..c703402bd2cd 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -193,6 +193,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void; reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; @@ -855,22 +856,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), + // Reordering is not a choice about what the panel shows, so it leaves the + // user-action revision alone and proactive opens still apply. + moveSurface: (ref, surfaceId, toIndex) => + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (fromIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1)); + return { ...current, surfaces }; + }), + ), reconcileBrowserSurfaces: (ref, tabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); - const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); - const existingBrowser = current.surfaces.filter( - (surface): surface is Extract => - surface.kind === "preview" && - surface.id !== "browser:new" && - validIds.has(surface.id), + // Filtered in place so browser tabs keep the order the user dragged them into. + const kept = current.surfaces.filter( + (surface) => + surface.kind !== "preview" || + (surface.id !== "browser:new" && validIds.has(surface.id)), ); - const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const knownIds = new Set(kept.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...kept, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 1b7239a0bef5..89f185ba573a 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2105,6 +2105,10 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined} From 74696941637aecace0f611eee6ceb2a7190b4c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 04:35:04 +0200 Subject: [PATCH 2/8] feat(server): a thread's Environment browser tabs live in its holder file Each thread keeps its tabs of the Environment browser in the holder file the view's hub reads, $XDG_RUNTIME_DIR/lazurio-browser/holders/t3-.json (LazurioPlatform F47 points 4, 5 and 10), so a tab the thread shows stays alive and every device sees the same strip. Beside the ids the file keeps each tab's record time on the boot clock, so the server reconciles with the hub's tabs.json as the Launchpad does: at the start, on every change of the list, every 30 s and before every write. A write re-reads its file first and never writes over one it cannot read. T3 Code's database stays upstream's. The web reaches a thread's tabs at /api/lazurio/thread-tabs: a snapshot, a server-sent event stream (no-transform, a whole snapshot per event, another Environment's tab as a live-only event) and hold and release, authenticated as every other HTTP route is, with upstream's preview scopes. A tab joins the strip when the person holds it, when preview_open opens or adopts it (revealed unless open: false), and when the thread's own agent-browser session binds another tab. A snapshot names the other places that hold a tab and whether an agent works in it. At most 128 tabs per thread. holders.ts copies the Platform's helpers from src/browser/people/holds.ts at 49d54c9 and runs its vectors; it also carries the daemon stop the next commit uses. The overlay's server layers join upstream's routes through one line of server.ts. The service is off without an absolute XDG_RUNTIME_DIR or while the Environment declares no browser. Co-Authored-By: Claude Opus 5.5 --- .../src/lazurio/environmentBrowser.test.ts | 40 + apps/server/src/lazurio/environmentBrowser.ts | 35 +- apps/server/src/lazurio/holders.test.ts | 460 ++++++++++ apps/server/src/lazurio/holders.ts | 427 ++++++++++ apps/server/src/lazurio/overlay.ts | 18 + apps/server/src/lazurio/threadLifecycle.ts | 61 ++ apps/server/src/lazurio/threadTabs.test.ts | 572 +++++++++++++ apps/server/src/lazurio/threadTabs.ts | 796 ++++++++++++++++++ .../src/lazurio/threadTabsRoute.test.ts | 461 ++++++++++ apps/server/src/lazurio/threadTabsRoute.ts | 168 ++++ apps/server/src/server.ts | 8 +- 11 files changed, 3030 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/lazurio/holders.test.ts create mode 100644 apps/server/src/lazurio/holders.ts create mode 100644 apps/server/src/lazurio/overlay.ts create mode 100644 apps/server/src/lazurio/threadLifecycle.ts create mode 100644 apps/server/src/lazurio/threadTabs.test.ts create mode 100644 apps/server/src/lazurio/threadTabs.ts create mode 100644 apps/server/src/lazurio/threadTabsRoute.test.ts create mode 100644 apps/server/src/lazurio/threadTabsRoute.ts diff --git a/apps/server/src/lazurio/environmentBrowser.test.ts b/apps/server/src/lazurio/environmentBrowser.test.ts index 3b23f8d63aa1..da5628b61dfe 100644 --- a/apps/server/src/lazurio/environmentBrowser.test.ts +++ b/apps/server/src/lazurio/environmentBrowser.test.ts @@ -31,6 +31,7 @@ import { PreviewStandardToolkit } from "../mcp/toolkits/preview/tools.ts"; import * as ProcessRunner from "../processRunner.ts"; import { agentBrowserSessionName } from "./agentBrowserSession.ts"; import * as EnvironmentBrowser from "./environmentBrowser.ts"; +import * as ThreadTabs from "./threadTabs.ts"; import type { EnvironmentBrowserConnection, EnvironmentBrowserTab, @@ -179,6 +180,23 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray }> = []; let registered = yield* Deferred.make(); const reported: Array = []; + // What the host tells the thread's strips (threadTabs.ts). + const recorded: Array<{ + readonly threadId: ThreadId; + readonly tabId: string; + readonly reveal: boolean; + }> = []; + const declarations: Array = []; + const threadTabs = ThreadTabs.ThreadTabs.of({ + read: () => Effect.die("unused"), + hold: () => Effect.die("unused"), + release: () => Effect.die("unused"), + events: () => Stream.never, + declare: (viewOrigin) => Effect.sync(() => declarations.push(viewOrigin)), + record: (thread, tabId, reveal) => + Effect.sync(() => recorded.push({ threadId: thread, tabId, reveal })), + foreign: () => Effect.void, + }); const delivered: Array = []; const deliveryWaiters: Array<{ readonly operation: PreviewAutomationOperation; @@ -249,6 +267,7 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(HostProcessEnvironment, { HOME: home }), Effect.provideService(EnvironmentBrowser.EnvironmentBrowserConnect, browser.connect), + Effect.provideService(ThreadTabs.ThreadTabs, threadTabs), ); return { broker, @@ -256,6 +275,8 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray runs, registrations, reported, + recorded, + declarations, setLink: (answer: CliAnswer) => { cli.link = answer; }, @@ -445,6 +466,21 @@ it.effect( visible: false, tabId: null, }); + + // Every preview_open recorded the tab in the thread's strip, with a reveal unless it + // asked for background work: the strip, not the activities, opens the person's panel. + expect(host.recorded.map((entry) => entry.reveal)).toEqual([ + false, + false, + true, + true, + true, + false, + true, + ]); + expect(new Set(host.recorded.map((entry) => `${entry.threadId}/${entry.tabId}`))).toEqual( + new Set([`${threadId}/${threadWindow}`]), + ); }), ), ); @@ -603,6 +639,10 @@ it.effect("answers preview_open of another Environment's view without this brows // Neither this Environment's browser nor its lazurio command was asked. expect(host.browser.connections).toHaveLength(0); expect(host.runs).toEqual([{ command: lazurio, args: ["browser", "link", "--json"] }]); + // No holder file records another Environment's tab. + expect(host.recorded).toEqual([]); + // The host's declaration is the strips' too. + expect(host.declarations).toEqual(["https://browser.example.test"]); }), ), ); diff --git a/apps/server/src/lazurio/environmentBrowser.ts b/apps/server/src/lazurio/environmentBrowser.ts index 6ab0cea7387f..8bbfc019b2d5 100644 --- a/apps/server/src/lazurio/environmentBrowser.ts +++ b/apps/server/src/lazurio/environmentBrowser.ts @@ -9,7 +9,9 @@ * The host registers with the preview broker as a preferred host and advertises every operation, * while the Environment declares its browser (`lazurio browser link --json`), asking again every * minute while it is not registered. A tab is a DevTools target id of the Environment browser. - * Per thread, the host keeps in memory the tabs it opened or adopted and the current one. + * Per thread, the host keeps in memory the tabs it opened or adopted and the current one, and + * every tab it opens or adopts joins the thread's strip (threadTabs.ts), revealed to the person + * unless preview_open asks for background work. * preview_open without a tab reuses the current tab while it is open (the tool's contract), and a * thread without one gets its own window, the one `lazurio browser window` keeps bound to the * thread's agent-browser session, so T3's tools and agent-browser drive the same window and a @@ -20,7 +22,8 @@ * Another Environment's view of a tab (an agent working in that Environment over SSH) is never * loaded here: preview_open answers with that tab, which the person's right panel opens as the * other Environment's, and says to drive it with agent-browser on that Environment. Any other - * operation on that tab fails with the same advice. + * operation on that tab fails with the same advice. The host's declaration of the browser is also + * the thread strips' (threadTabs.ts). */ import { @@ -65,6 +68,7 @@ import { ServerBrowserOperationError, toOperationError, } from "./environmentBrowserPage.ts"; +import * as ThreadTabs from "./threadTabs.ts"; export const ENVIRONMENT_BROWSER_CLIENT_ID = "lazurio-environment-browser"; @@ -140,13 +144,13 @@ const NO_TAB: PreviewAutomationStatus = { }; /** - * `visible` says the person is shown the tab. The person's web T3 opens its right panel on the - * thread's Environment browser when the thread's agent uses the browser - * (apps/web/src/lazurio/browserUse.ts), so the thread's current tab is shown once preview_open - * revealed it, while the tab stays open. preview_open reveals unless it asks for background work - * (`open: false`, or the deprecated `show: false`; `open` wins, as in the desktop app), and the - * thread's last preview_open decides. The host cannot see the person's screen: a panel the person - * closed, or a client without the panel, still counts as shown. + * `visible` says the person is shown the tab. A tab preview_open reveals joins the thread's strip + * with a reveal request, on which the person's web T3 opens its right panel on that tab + * (threadTabs.ts, apps/web/src/lazurio/threadTabs.ts), so the thread's current tab is shown once + * preview_open revealed it, while the tab stays open. preview_open reveals unless it asks for + * background work (`open: false`, or the deprecated `show: false`; `open` wins, as in the desktop + * app), and the thread's last preview_open decides. The host cannot see the person's screen: a + * panel the person closed, or a client without the panel, still counts as shown. */ const statusOf = ( targetId: string, @@ -214,6 +218,7 @@ export const make = Effect.gen(function* () { const runner = yield* ProcessRunner.ProcessRunner; const path = yield* Path.Path; const connect = yield* EnvironmentBrowserConnect; + const threadTabs = yield* ThreadTabs.ThreadTabs; const home = (yield* HostProcessEnvironment).HOME; const hostScope = yield* Effect.scope; const environmentId = yield* environment.getEnvironmentId; @@ -304,6 +309,7 @@ export const make = Effect.gen(function* () { ), }), ); + /** The thread's tab now: reported to the broker and recorded in the thread's strip. */ const adopt = (threadId: ThreadId, targetId: string, revealed: boolean) => Effect.suspend(() => { const thread = threads.get(threadId) ?? { @@ -315,7 +321,7 @@ export const make = Effect.gen(function* () { thread.current = targetId; thread.revealed = revealed; threads.set(threadId, thread); - return reportLiveTabs; + return reportLiveTabs.pipe(Effect.andThen(threadTabs.record(threadId, targetId, revealed))); }); /** Whether the person is shown the tab: the thread's current tab, once revealed (statusOf). */ const shown = (threadId: ThreadId, targetId: string) => { @@ -683,11 +689,16 @@ export const make = Effect.gen(function* () { }), ); // Registered while the Environment declares its browser. The broker ends a host's stream when - // a request goes unanswered; the host then registers again. + // a request goes unanswered; the host then registers again. The threads' strips share the + // declaration: without one, or without a link, they answer unavailable. yield* Effect.gen(function* () { const declaration = yield* browserDeclaration; - if (Option.isNone(declaration)) return yield* Effect.sleep(DECLARATION_CHECK_INTERVAL); + if (Option.isNone(declaration)) { + yield* threadTabs.declare(undefined); + return yield* Effect.sleep(DECLARATION_CHECK_INTERVAL); + } ownViewOrigin = viewOriginOf(declaration.value.link); + yield* threadTabs.declare(ownViewOrigin); yield* Effect.exit(hostSession); yield* Effect.sleep(HOST_RECONNECT_DELAY); }).pipe(Effect.forever, Effect.forkIn(hostScope)); diff --git a/apps/server/src/lazurio/holders.test.ts b/apps/server/src/lazurio/holders.test.ts new file mode 100644 index 000000000000..89faeeb20f23 --- /dev/null +++ b/apps/server/src/lazurio/holders.test.ts @@ -0,0 +1,460 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - the holder files are real files in a temporary runtime directory, and the watch is the operating system's. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + agentBrowserSocketDirectory, + daemonWorking, + holderDirectory, + holderFileText, + isStrip, + keptTabs, + liveTabsPath, + parseBootTime, + parseHolder, + parseHolderRecords, + parseLiveTabs, + processAlive, + readBootClock, + readSessionBindings, + replaceOwnerFile, + stopSessionDaemon, + watchDirectories, +} from "./holders.ts"; + +// The vectors below are LazurioPlatform's for the same helpers (tests/environment-browser-holds.test.ts +// and the holder-file tests of tests/launchpad-new-tab.test.ts at 49d54c9), with the same inputs +// and outputs, so the copy in holders.ts cannot drift from the hub's rules unnoticed. + +const id = (n: number) => n.toString(16).toUpperCase().padStart(32, "0"); +const boot = "6f2c1d0e-1a2b-4c3d-8e4f-5a6b7c8d9e0f"; +const earlierBoot = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; +const at = (seconds: number, ofBoot: string = boot) => ({ boot: ofBoot, at: seconds }); + +let root: string | undefined; +afterEach(async () => { + if (root) await NodeFSP.rm(root, { recursive: true, force: true }); + root = undefined; +}); +const temporary = async (prefix: string) => { + root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix)); + return root; +}; + +describe("the Platform's vectors", () => { + it("a strip is a file name in agent-browser's session alphabet", () => { + for (const name of ["launchpad", "t3-abc_DEF-123", "x".repeat(64)]) { + expect(isStrip(name)).toBe(true); + } + for (const name of ["", "../x", "a/b", "a.b", "x".repeat(65), 7, null]) { + expect(isStrip(name)).toBe(false); + } + }); + + it("the holder directory and the list live in the runtime directory, never elsewhere", () => { + expect(holderDirectory({ XDG_RUNTIME_DIR: "/run/user/1000" })).toBe( + "/run/user/1000/lazurio-browser/holders", + ); + expect(holderDirectory({ XDG_RUNTIME_DIR: "run/user" })).toBeUndefined(); + expect(holderDirectory({})).toBeUndefined(); + expect(liveTabsPath({ XDG_RUNTIME_DIR: "/run/user/1000" })).toBe( + "/run/user/1000/lazurio-browser/tabs.json", + ); + expect(liveTabsPath({})).toBeUndefined(); + // agent-browser's own directory: AGENT_BROWSER_SOCKET_DIR when absolute (window.ts). + expect(agentBrowserSocketDirectory({ XDG_RUNTIME_DIR: "/run/user/1000" })).toBe( + "/run/user/1000/agent-browser", + ); + expect( + agentBrowserSocketDirectory({ AGENT_BROWSER_SOCKET_DIR: "/srv/ab", XDG_RUNTIME_DIR: "/r" }), + ).toBe("/srv/ab"); + expect( + agentBrowserSocketDirectory({ AGENT_BROWSER_SOCKET_DIR: "ab", XDG_RUNTIME_DIR: "/r" }), + ).toBe("/r/agent-browser"); + expect(agentBrowserSocketDirectory({})).toBeUndefined(); + }); + + it("a holder file holds only target ids, under its own strip name", () => { + const text = (value: unknown) => JSON.stringify(value); + expect( + parseHolder( + "t3-a.json", + text({ + strip: "t3-a", + label: " Vlákno o webu ", + tabs: [id(1), id(1).toLowerCase(), "nope", 5, id(2)], + }), + ), + ).toEqual({ strip: "t3-a", label: "Vlákno o webu", tabs: [id(1), id(2)] }); + // The label is optional and cut; the strip must be the file's name. + expect(parseHolder("launchpad.json", text({ strip: "launchpad", tabs: [] }))).toEqual({ + strip: "launchpad", + label: "", + tabs: [], + }); + expect( + parseHolder("launchpad.json", text({ strip: "launchpad", label: "x".repeat(500), tabs: [] })) + ?.label.length, + ).toBe(200); + for (const [name, body] of [ + ["t3-a.json", text({ strip: "t3-b", tabs: [] })], + ["t3-a.tmp", text({ strip: "t3-a", tabs: [] })], + ["..json", text({ strip: ".", tabs: [] })], + ["t3-a.json", text({ strip: "t3-a", tabs: "x" })], + ["t3-a.json", text([id(1)])], + ["t3-a.json", "{"], + ["t3-a.json", text({ strip: "t3-a", tabs: [], pad: "x".repeat(70_000) })], + ] as const) { + expect(parseHolder(name, body)).toBeNull(); + } + // At most 500 tabs per holder. + expect( + parseHolder( + "t3-a.json", + text({ strip: "t3-a", tabs: Array.from({ length: 600 }, (_, n) => id(n + 1)) }), + )?.tabs.length, + ).toBe(500); + }); + + it("a .pid releases a tab only when it names a well-formed process id that ended", async () => { + const sockets = await temporary("holders-pid-"); + await NodeFSP.writeFile( + NodePath.join(sockets, "codex-1.target"), + JSON.stringify({ targetId: id(3) }), + ); + const working = async (pid: string | null, alive = false) => { + if (pid === null) await NodeFSP.rm(NodePath.join(sockets, "codex-1.pid"), { force: true }); + else await NodeFSP.writeFile(NodePath.join(sockets, "codex-1.pid"), pid); + const [binding] = await readSessionBindings(sockets, () => alive); + return binding?.working; + }; + // Unclear: empty, blank, zero, signed, pid 1, too long, not a number. + for (const unclear of ["", " ", "\n", "0", "-1", "1", "+5", "4.2", "99999999999", "x"]) { + expect(await working(unclear)).toBe(true); + } + // A well-formed id: working while it runs, ended once it does not. + expect(await working("4242\n", true)).toBe(true); + expect(await working("4242")).toBe(false); + // No .pid: the daemon ended (agent-browser removes it on exit). + expect(await working(null)).toBe(false); + expect(daemonWorking(null, () => true)).toBe(false); + }); + + it("the sessions' bindings are read with their daemons, and files that are no binding skipped", async () => { + const sockets = await temporary("holders-bindings-"); + const write = (name: string, value: unknown) => + NodeFSP.writeFile( + NodePath.join(sockets, name), + typeof value === "string" ? value : JSON.stringify(value), + ); + // A live daemon, an ended one (its binding stays), a binding without a daemon file, a daemon + // file that cannot be understood (working, so its tab stays), and files that are no binding. + await write("codex-1.target", { targetId: id(3) }); + await write("codex-1.pid", "4242\n"); + await write("t3-old.target", { targetId: id(4).toLowerCase() }); + await write("t3-old.pid", "4343"); + await write("claude-2.target", { targetId: id(3) }); + await write("odd-3.target", { targetId: id(5) }); + await write("odd-3.pid", "not a pid"); + await write("codex-1.sock", ""); + await write("short.target", { targetId: "abc" }); + // Here a binding that cannot be read only takes its agent's mark off (the hub keeps its last + // reading instead). + await write("broken.target", "{"); + expect(await readSessionBindings(sockets, (pid) => pid === 4242)).toEqual([ + { session: "claude-2", targetId: id(3), working: false }, + { session: "codex-1", targetId: id(3), working: true }, + { session: "odd-3", targetId: id(5), working: true }, + { session: "t3-old", targetId: id(4), working: false }, + ]); + expect(await readSessionBindings(NodePath.join(sockets, "none"))).toEqual([]); + }); + + it("a process is alive while kill -0 reaches it", () => { + expect(processAlive(process.pid)).toBe(true); + for (const pid of [0, 1, -5, Number.NaN, 1.5]) expect(processAlive(pid)).toBe(false); + expect(processAlive(2 ** 22 + 12_345)).toBe(false); + }); + + it("a change in a watched directory is reported, and a directory made later is watched on retry", async () => { + const directory = await temporary("holders-watch-"); + const holders = NodePath.join(directory, "holders"); + const later = NodePath.join(directory, "agent-browser"); + await NodeFSP.mkdir(holders); + const watched = { changes: 0 }; + let seenChange: (() => void) | undefined; + const watcher = watchDirectories([holders, later, undefined], () => { + watched.changes++; + seenChange?.(); + }); + // A watch may miss a change made the instant it starts (macOS), which the beat covers; the + // test writes until the watch sees one, waiting on the watch itself. + const seen = async (path: string) => { + const before = watched.changes; + for (let n = 0; watched.changes === before; n++) { + if (n > 100) throw new Error("no change seen"); + const reported = new Promise((resolve) => { + seenChange = resolve; + }); + await NodeFSP.writeFile(path, `{"n":${n}}`); + await Promise.race([reported, new Promise((resolve) => setTimeout(resolve, 50))]); + } + }; + try { + await seen(NodePath.join(holders, "launchpad.json")); + await NodeFSP.mkdir(later); + watcher.retry(); + await seen(NodePath.join(later, "codex-1.target")); + } finally { + watcher.stop(); + } + }); + + it("the boot clock reads the boot's id and the seconds since it", async () => { + const files: Record = { + "/proc/sys/kernel/random/boot_id": `${boot}\n`, + "/proc/uptime": "12345.67 54321.00\n", + }; + const read = async (path: string) => { + const text = files[path]; + if (text === undefined) throw new Error("ENOENT"); + return text; + }; + expect(await readBootClock(read)).toEqual(at(12345.67)); + files["/proc/uptime"] = "x y"; + expect(await readBootClock(read)).toBeNull(); + // Not Linux: no boot clock. + expect( + await readBootClock(async () => { + throw new Error("ENOENT"); + }), + ).toBeNull(); + expect(parseBootTime(at(1))).toEqual(at(1)); + for (const value of [null, {}, { boot, at: -1 }, { boot: "x", at: 1 }, { boot, at: "1" }]) { + expect(parseBootTime(value)).toBeNull(); + } + }); + + it("the list of live tabs is replaced atomically, owner-only, and read back", async () => { + const runtime = await temporary("holders-list-"); + const path = liveTabsPath({ XDG_RUNTIME_DIR: runtime })!; + // The hub's writeLiveTabs is replaceOwnerFile of this text. + const writeLiveTabs = (tabs: readonly string[], takenAt: ReturnType) => + replaceOwnerFile(path, `${JSON.stringify({ tabs, takenAt })}\n`); + await writeLiveTabs([id(1), id(2)], at(100)); + await writeLiveTabs([id(3)], at(105.5)); + expect(parseLiveTabs(await NodeFSP.readFile(path, "utf8"))).toEqual({ + tabs: [id(3)], + takenAt: at(105.5), + }); + expect((await NodeFSP.stat(path)).mode & 0o777).toBe(0o600); + expect(await NodeFSP.readdir(NodePath.join(runtime, "lazurio-browser"))).toEqual(["tabs.json"]); + for (const text of [ + "{", + "[]", + JSON.stringify({ tabs: [id(1)] }), + JSON.stringify({ tabs: [id(1)], takenAt: "2026-10-11T12:00:00Z" }), + JSON.stringify({ tabs: ["x"], takenAt: at(1) }), + JSON.stringify({ tabs: id(1), takenAt: at(1) }), + ]) { + expect(parseLiveTabs(text)).toBeNull(); + } + }); + + it("an app drops a stored tab only when a list of this boot taken after its record lacks it", () => { + const list = { tabs: [id(1)], takenAt: at(100) }; + const stored = [ + { id: id(1), recordedAt: at(10) }, + { id: id(2), recordedAt: at(90) }, + { id: id(3), recordedAt: at(100) }, + { id: id(4).toLowerCase(), recordedAt: at(100.5) }, + { id: id(5), recordedAt: null }, + { id: id(6), recordedAt: at(5_000, earlierBoot) }, + ]; + // id(2) ended before the snapshot began. id(5) has no time and id(6) is from an earlier boot, + // so both count as older than every list of this boot. id(3) and id(4) were recorded when or + // after it began. + expect(keptTabs(stored, list, boot)).toEqual([id(1), id(3), id(4).toLowerCase()]); + // A list of another boot, no list, or no boot clock: nothing is dropped. + const all = stored.map((tab) => tab.id); + expect(keptTabs(stored, { tabs: [], takenAt: at(100, earlierBoot) }, boot)).toEqual(all); + expect(keptTabs(stored, null, boot)).toEqual(all); + expect(keptTabs(stored, list, null)).toEqual(all); + }); + + it("a record of a tab that had already ended goes with the hub's next periodic snapshot", () => { + // The tab ended at 199 s and the hub's snapshot then lacks it; the app recorded it only at + // 200 s, so that snapshot cannot drop it. + const record = [{ id: id(6), recordedAt: at(200) }]; + expect(keptTabs(record, { tabs: [], takenAt: at(199) }, boot)).toEqual([id(6)]); + // The periodic snapshot 30 s later began after the record and lacks it. + expect(keptTabs(record, { tabs: [], takenAt: at(229) }, boot)).toEqual([]); + }); + + it("a step of the wall clock changes nothing: a record made after a snapshot began is kept", () => { + // The snapshot began at boot second 100; the wall clock then stepped back ten seconds; the + // app recorded a live tab, which the boot clock reads as 100.4. The record is later than the + // snapshot, so the tab stays. + const record = [{ id: id(7), recordedAt: at(100.4) }]; + expect(keptTabs(record, { tabs: [], takenAt: at(100) }, boot)).toEqual([id(7)]); + }); + + it("a holder file keeps its record times, which only its writer reads back", () => { + const text = holderFileText("launchpad", "Launchpad", [ + { id: id(1), recordedAt: at(100.25) }, + { id: id(2), recordedAt: at(101) }, + { id: id(3), recordedAt: null }, + ]); + expect(JSON.parse(text)).toEqual({ + strip: "launchpad", + label: "Launchpad", + tabs: [id(1), id(2), id(3)], + recordedAt: { [id(1)]: at(100.25), [id(2)]: at(101) }, + }); + // The hub reads it as any holder file and ignores the times. + expect(parseHolder("launchpad.json", text)).toEqual({ + strip: "launchpad", + label: "Launchpad", + tabs: [id(1), id(2), id(3)], + }); + // A time that is none is null; an id that is none is not a tab. + expect( + parseHolderRecords( + "launchpad.json", + JSON.stringify({ + strip: "launchpad", + label: "Launchpad", + tabs: [id(1), id(2), id(3)], + recordedAt: { + [id(1).toLowerCase()]: at(5), + [id(2)]: { boot: "x", at: 1 }, + nope: at(1), + }, + }), + )?.tabs, + ).toEqual([ + { id: id(1), recordedAt: at(5) }, + { id: id(2), recordedAt: null }, + { id: id(3), recordedAt: null }, + ]); + }); +}); + +describe("T3 Code's holder files", () => { + it("are read by the hub's rules: strip is the file name, ids upper case, labels cut at 200", () => { + const strip = "t3-4a1f9c2e-7b3d-4e5f-8a6b-9c0d1e2f3a4b"; + const text = holderFileText(strip, "x".repeat(200), [ + { id: "abcdef0123456789abcdef0123456789".toUpperCase(), recordedAt: at(3) }, + ]); + expect(parseHolder(`${strip}.json`, text)).toEqual({ + strip, + label: "x".repeat(200), + tabs: ["ABCDEF0123456789ABCDEF0123456789"], + }); + expect(parseHolder("t3-another.json", text)).toBeNull(); + }); + + it("stay under 20 KiB with 128 tabs and a 200-character label", () => { + const text = holderFileText( + `t3-${"f".repeat(61)}`, + "ž".repeat(200), + Array.from({ length: 128 }, (_, n) => ({ id: id(n + 1), recordedAt: at(123_456.789) })), + ); + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(20 * 1024); + }); + + it("are replaced through a .tmp file, owner-only, in an owner-only directory", async () => { + const runtime = await temporary("holders-write-"); + const directory = holderDirectory({ XDG_RUNTIME_DIR: runtime })!; + const path = NodePath.join(directory, "t3-a.json"); + await replaceOwnerFile(path, holderFileText("t3-a", "A", [])); + // A reader listing the directory meanwhile sees the file and at most a temporary one whose + // name never ends in .json, so the hub skips it; it never meets half a file. + const seen = new Set(); + const writes = new AbortController(); + let torn = 0; + const reader = (async () => { + while (!writes.signal.aborted) { + for (const name of await NodeFSP.readdir(directory)) seen.add(name); + const text = await NodeFSP.readFile(path, "utf8").catch(() => null); + if (text !== null && parseHolder("t3-a.json", text) === null) torn += 1; + await new Promise((resolve) => setImmediate(resolve)); + } + })(); + for (let n = 0; n < 40; n++) { + await replaceOwnerFile( + path, + holderFileText("t3-a", "A", [{ id: id(n + 1), recordedAt: null }]), + ); + } + writes.abort(); + await reader; + expect(torn).toBe(0); + for (const name of seen) expect(name).toMatch(/^t3-a\.json(?:\.[0-9a-f]{12}\.tmp)?$/); + expect(await NodeFSP.readdir(directory)).toEqual(["t3-a.json"]); + expect((await NodeFSP.stat(path)).mode & 0o777).toBe(0o600); + expect((await NodeFSP.stat(directory)).mode & 0o777).toBe(0o700); + // A write that fails leaves no temporary file behind. + await NodeFSP.chmod(directory, 0o500); + try { + await expect(replaceOwnerFile(NodePath.join(directory, "t3-b.json"), "{}")).rejects.toThrow(); + } finally { + await NodeFSP.chmod(directory, 0o700); + } + expect(await NodeFSP.readdir(directory)).toEqual(["t3-a.json"]); + }); +}); + +describe("stopping a thread's agent-browser daemon", () => { + const setup = async (pid: string | null) => { + const sockets = await temporary("holders-daemon-"); + if (pid !== null) await NodeFSP.writeFile(NodePath.join(sockets, "t3-a.pid"), pid); + const terminated: number[] = []; + return { sockets, terminated, terminate: (target: number) => terminated.push(target) }; + }; + + it("sends SIGTERM only to a live agent-browser process", async () => { + const { sockets, terminated, terminate } = await setup("4242\n"); + const commandLine = async () => "/home/u/.local/bin/agent-browser-linux-x64\0"; + expect( + await stopSessionDaemon(sockets, "t3-a", { alive: () => true, commandLine, terminate }), + ).toBe("stopped"); + expect(terminated).toEqual([4242]); + }); + + it("never signals a reused pid, an ended one or an unclear one", async () => { + const reused = await setup("4242"); + expect( + await stopSessionDaemon(reused.sockets, "t3-a", { + alive: () => true, + commandLine: async () => "/usr/bin/python3\0server.py\0", + terminate: reused.terminate, + }), + ).toBe("not-agent-browser"); + expect(reused.terminated).toEqual([]); + for (const [pid, alive] of [ + ["4242", false], + ["1", true], + ["", true], + ["x", true], + ] as const) { + const fixture = await setup(pid); + expect( + await stopSessionDaemon(fixture.sockets, "t3-a", { + alive: () => alive, + commandLine: async () => "agent-browser\0", + terminate: fixture.terminate, + }), + ).toBe("none"); + expect(fixture.terminated).toEqual([]); + } + // No .pid: no daemon runs. + const none = await setup(null); + expect(await stopSessionDaemon(none.sockets, "t3-a", { terminate: none.terminate })).toBe( + "none", + ); + expect(none.terminated).toEqual([]); + }); +}); diff --git a/apps/server/src/lazurio/holders.ts b/apps/server/src/lazurio/holders.ts new file mode 100644 index 000000000000..12583e7b8bf7 --- /dev/null +++ b/apps/server/src/lazurio/holders.ts @@ -0,0 +1,427 @@ +// @effect-diagnostics nodeBuiltinImport:off - The holder files are the Platform's, read and written as it does. + +/** + * Lazurio overlay (plan DEV-6646, task 501; LazurioPlatform decision F47 points 4 and 5): who + * keeps a remote tab of the Environment browser, as T3 Code's server writes and reads it. + * + * The view's hub alone ends a remote tab. It keeps one while a holder file lists it, + * `$XDG_RUNTIME_DIR/lazurio-browser/holders/.json` (a T3 Code thread's strip is its + * agent-browser session, `t3-`; the Launchpad's is `launchpad`), or while an agent works + * in it: an agent-browser session bound to it (`/.target`) whose daemon runs + * (`.pid`). Its list of the browser's live tabs, `tabs.json`, is what every app + * reconciles its holds with, on the Environment's boot clock. + * + * Copied from LazurioPlatform `src/browser/people/holds.ts` at 49d54c9 (F47 step 2, on step 1 + * a2cd44d): BootTime, readBootClock, parseBootTime, RecordedTab, LiveTabs, parseLiveTabs, + * keptTabs, isStrip, holderDirectory, liveTabsPath, parseHolder, holderFileText, + * parseHolderRecords, replaceOwnerFile, processAlive and `watchDirectories` (the Platform's + * `watchHolds`). holders.test.ts runs the Platform's vectors, so a drift between the copies fails + * there. Added here: the hub's rule for a daemon that runs (`daemonWorking`, from `readHolds`), + * the bindings of agent-browser's sessions, and the stop of a thread's daemon. + */ + +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +/** A strip's name is a file name: agent-browser's session alphabet, as T3 + * Code's `t3-` and the Launchpad's `launchpad` are. */ +const stripPattern = /^[A-Za-z0-9_-]{1,64}$/; +const targetIdPattern = /^[0-9A-Fa-f]{32}$/; +const maxHolderBytes = 64 * 1024; +const maxTabsPerHolder = 500; +const maxLabelLength = 200; + +export function isStrip(value: unknown): value is string { + return typeof value === "string" && stripPattern.test(value); +} + +/** A DevTools target id: 32 hexadecimal digits. */ +export function isTargetId(value: unknown): value is string { + return typeof value === "string" && targetIdPattern.test(value); +} + +/** `$XDG_RUNTIME_DIR/lazurio-browser/holders`, where the apps' servers write + * their holds; undefined without an absolute runtime directory. */ +export function holderDirectory( + env: Readonly>, +): string | undefined { + const runtime = env.XDG_RUNTIME_DIR; + return runtime !== undefined && NodePath.isAbsolute(runtime) + ? NodePath.join(runtime, "lazurio-browser", "holders") + : undefined; +} + +/** `$XDG_RUNTIME_DIR/lazurio-browser/tabs.json`: the hub's list of the + * browser's live page targets, which every app's server reconciles its holds + * with (F47 point 4). */ +export function liveTabsPath( + env: Readonly>, +): string | undefined { + const runtime = env.XDG_RUNTIME_DIR; + return runtime !== undefined && NodePath.isAbsolute(runtime) + ? NodePath.join(runtime, "lazurio-browser", "tabs.json") + : undefined; +} + +/** Where agent-browser keeps its sockets and a session's tab binding + * (`AGENT_BROWSER_SOCKET_DIR` when absolute, else `$XDG_RUNTIME_DIR/agent-browser`), as the + * Platform's `agentBrowserSocketDirectory` (`src/browser/window.ts`). */ +export function agentBrowserSocketDirectory( + env: Readonly>, +): string | undefined { + const named = env.AGENT_BROWSER_SOCKET_DIR; + if (named !== undefined && NodePath.isAbsolute(named)) return named; + const runtime = env.XDG_RUNTIME_DIR; + return runtime !== undefined && NodePath.isAbsolute(runtime) + ? NodePath.join(runtime, "agent-browser") + : undefined; +} + +/** A time on the Environment's boot clock (F47 point 4): the boot's id and + * the seconds since it. Unlike the wall clock it never steps back, and every + * process of the Environment reads the same one, so it orders an app's record + * against the hub's snapshot. */ +export type BootTime = Readonly<{ boot: string; at: number }>; + +const bootIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Reads the boot clock: `/proc/sys/kernel/random/boot_id` and the first + * number of `/proc/uptime` (CLOCK_BOOTTIME). Null where there is none: not + * Linux, where no Environment browser runs. */ +export async function readBootClock( + read: (path: string) => Promise = (path) => NodeFSP.readFile(path, "utf8"), +): Promise { + try { + const boot = (await read("/proc/sys/kernel/random/boot_id")).trim(); + const at = Number.parseFloat((await read("/proc/uptime")).trim().split(/\s+/)[0] ?? ""); + return bootIdPattern.test(boot) && Number.isFinite(at) && at >= 0 ? { boot, at } : null; + } catch { + return null; + } +} + +/** A boot-clock time as it is stored, or null when the value is not one. */ +export function parseBootTime(value: unknown): BootTime | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + return typeof record.boot === "string" && + bootIdPattern.test(record.boot) && + typeof record.at === "number" && + Number.isFinite(record.at) && + record.at >= 0 + ? { boot: record.boot, at: record.at } + : null; +} + +/** A stored tab of an app with the boot-clock time it recorded the tab, or + * null for a tab without one, which counts as recorded long ago (F47 point + * 4). */ +export type RecordedTab = Readonly<{ id: string; recordedAt: BootTime | null }>; + +/** One snapshot of the browser's page targets: `takenAt` is the boot-clock + * time the hub began it, before it asked the browser. */ +export type LiveTabs = Readonly<{ tabs: readonly string[]; takenAt: BootTime }>; + +/** Replaces one file of `lazurio-browser` atomically and owner-only (F47 + * point 4): a temporary file of mode 0600 renamed over it, so a reader sees + * the old text or the new one, never a part. Its directory is made with mode + * 0700 when missing. The temporary name never ends in `.json`, so the hub skips it. */ +export async function replaceOwnerFile(path: string, text: string): Promise { + await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${NodeCrypto.randomBytes(6).toString("hex")}.tmp`; + try { + await NodeFSP.writeFile(temporary, text, { mode: 0o600 }); + await NodeFSP.rename(temporary, path); + } catch (error) { + await NodeFSP.rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +/** The list a reader finds, or null when the file is not one. */ +export function parseLiveTabs(text: string): LiveTabs | null { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + const takenAt = parseBootTime(record.takenAt); + if (!Array.isArray(record.tabs) || takenAt === null) return null; + if (!record.tabs.every((id): id is string => typeof id === "string" && targetIdPattern.test(id))) + return null; + return { + tabs: record.tabs.map((id) => (id as string).toUpperCase()), + takenAt, + }; +} + +/** An app's reconciliation (F47 point 4): the ids it keeps from its stored + * ones, `boot` being the current boot. A list of another boot is ignored. + * An id the list lacks goes when the list was taken after the app recorded + * it: a snapshot lists every target that exists when it begins, so such an + * id had ended by then, while a tab recorded after the snapshot began stays. + * A record from an earlier boot, or without a time, is older than every list + * of this boot. Without a list, or without the boot clock, it keeps + * everything. */ +export function keptTabs( + stored: readonly RecordedTab[], + list: LiveTabs | null, + boot: string | null, +): string[] { + if (list === null || boot === null || list.takenAt.boot !== boot) + return stored.map((tab) => tab.id); + const live = new Set(list.tabs); + return stored + .filter( + (tab) => + live.has(tab.id.toUpperCase()) || + (tab.recordedAt !== null && + tab.recordedAt.boot === boot && + tab.recordedAt.at >= list.takenAt.at), + ) + .map((tab) => tab.id); +} + +/** The hold one holder file records, or null when the file is not one: its + * `strip` must be its file name, and only target ids count. */ +export function parseHolder( + fileName: string, + text: string, +): Readonly<{ strip: string; label: string; tabs: readonly string[] }> | null { + if (!fileName.endsWith(".json")) return null; + const name = fileName.slice(0, -".json".length); + if (!isStrip(name) || text.length > maxHolderBytes) return null; + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + if (record.strip !== name || !Array.isArray(record.tabs)) return null; + const label = + typeof record.label === "string" ? record.label.trim().slice(0, maxLabelLength) : ""; + const tabs = [ + ...new Set( + record.tabs + .filter((id): id is string => typeof id === "string" && targetIdPattern.test(id)) + .map((id) => id.toUpperCase()), + ), + ].slice(0, maxTabsPerHolder); + return { strip: name, label, tabs }; +} + +/** The text of a holder file that also keeps each tab's record time, + * `recordedAt`, by its id: the hub ignores it, and only the app that writes + * the file reads it back (`parseHolderRecords`), so its reconciliation + * survives a restart. A tab without a time has no entry. */ +export function holderFileText(strip: string, label: string, tabs: readonly RecordedTab[]): string { + const recordedAt: Record = {}; + for (const tab of tabs) + if (tab.recordedAt !== null) + recordedAt[tab.id] = { boot: tab.recordedAt.boot, at: tab.recordedAt.at }; + return `${JSON.stringify({ + strip, + label, + tabs: tabs.map((tab) => tab.id), + recordedAt, + })}\n`; +} + +/** A holder file with the record time of each of its tabs, or null when the + * file is not a holder file (`parseHolder`'s rules). A tab whose time is + * missing or not a boot-clock time has null. */ +export function parseHolderRecords( + fileName: string, + text: string, +): Readonly<{ + strip: string; + label: string; + tabs: readonly RecordedTab[]; +}> | null { + const hold = parseHolder(fileName, text); + if (hold === null) return null; + const times = new Map(); + const stored = (JSON.parse(text) as Record).recordedAt; + if (typeof stored === "object" && stored !== null && !Array.isArray(stored)) + for (const [id, value] of Object.entries(stored)) { + const at = parseBootTime(value); + if (targetIdPattern.test(id) && at !== null) times.set(id.toUpperCase(), at); + } + return { + strip: hold.strip, + label: hold.label, + tabs: hold.tabs.map((id) => ({ id, recordedAt: times.get(id) ?? null })), + }; +} + +/** True while a process with this id runs (one of another user counts too, + * as `kill -0` answers EPERM for it). */ +export function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid < 2) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** A `.pid` file's process id when it is a well-formed one of at least 2, else null. */ +export function daemonProcessId(text: string): number | null { + const digits = text.trim(); + const pid = /^[0-9]{1,10}$/.test(digits) ? Number(digits) : Number.NaN; + return Number.isSafeInteger(pid) && pid >= 2 ? pid : null; +} + +/** The hub's rule for an agent at work (`readHolds`): a session counts as ended only when its + * `.pid` is gone (null here) or names a well-formed process id that no longer runs; one that + * cannot be understood (empty, signed, zero) counts as working. */ +export function daemonWorking(pidText: string | null, alive: (pid: number) => boolean): boolean { + if (pidText === null) return false; + const pid = daemonProcessId(pidText); + return pid === null || alive(pid); +} + +/** True for "no such file or directory": the one read failure that means nothing is there. */ +export const isMissing = (error: unknown) => + (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; + +/** A directory's names; one that does not exist is empty. Any other failure is thrown. */ +export async function directoryNames(directory: string): Promise { + try { + return (await NodeFSP.readdir(directory)).sort(); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } +} + +/** A file's text; null when it is gone. Any other failure is thrown. */ +export async function fileText( + path: string, + read: (path: string) => Promise = (file) => NodeFSP.readFile(file, "utf8"), +): Promise { + try { + return await read(path); + } catch (error) { + if (isMissing(error)) return null; + throw error; + } +} + +/** One agent-browser session's binding: the tab it drives and whether its daemon runs. */ +export type SessionBinding = Readonly<{ session: string; targetId: string; working: boolean }>; + +/** + * The bindings in agent-browser's socket directory, by session, sorted. A binding file that + * cannot be read or understood is skipped here: it only takes an agent's mark off a tab, unlike + * in the hub, which keeps its last reading for a read it could not make. + */ +export async function readSessionBindings( + directory: string, + alive: (pid: number) => boolean = processAlive, + read?: (path: string) => Promise, +): Promise { + const bindings: SessionBinding[] = []; + for (const name of await directoryNames(directory)) { + if (!name.endsWith(".target")) continue; + const session = name.slice(0, -".target".length); + if (!isStrip(session)) continue; + let targetId: unknown; + try { + const content = await fileText(NodePath.join(directory, name), read); + if (content === null) continue; + targetId = (JSON.parse(content) as { targetId?: unknown } | null)?.targetId; + } catch { + continue; + } + if (!isTargetId(targetId)) continue; + const pidText = await fileText(NodePath.join(directory, `${session}.pid`), read).catch( + () => "", + ); + bindings.push({ + session, + targetId: targetId.toUpperCase(), + working: daemonWorking(pidText, alive), + }); + } + return bindings; +} + +/** What the daemon stop needs of the host: overridable in tests (a fake process table). */ +export type DaemonSeams = Readonly<{ + alive?: (pid: number) => boolean; + /** `/proc//cmdline`, NUL-separated. */ + commandLine?: (pid: number) => Promise; + terminate?: (pid: number) => void; + read?: (path: string) => Promise; +}>; + +/** The daemon is agent-browser's own executable, re-run with AGENT_BROWSER_DAEMON=1 and no + * arguments (agent-browser `cli/src/connection.rs`), so its command line names agent-browser. */ +const namesAgentBrowser = (commandLine: string) => + /^agent-browser(?:[-_.][A-Za-z0-9_.-]*)?$/.test( + NodePath.basename(commandLine.split("\0")[0] ?? ""), + ); + +/** + * Stops a session's agent-browser daemon: SIGTERM to the process its `.pid` names, only when + * that is a well-formed id of a live process whose command line is agent-browser's. On SIGTERM + * the daemon of an attached browser disconnects without closing a tab and removes its `.pid`, so + * its binding no longer counts for the hub. Never `agent-browser close`, which first spawns a + * daemon when none runs. Resolves what happened; a failed signal is thrown. + */ +export async function stopSessionDaemon( + socketDirectory: string, + session: string, + seams: DaemonSeams = {}, +): Promise<"stopped" | "none" | "not-agent-browser"> { + if (!isStrip(session)) return "none"; + const pidText = await fileText(NodePath.join(socketDirectory, `${session}.pid`), seams.read); + const pid = pidText === null ? null : daemonProcessId(pidText); + if (pid === null || !(seams.alive ?? processAlive)(pid)) return "none"; + const commandLine = await ( + seams.commandLine ?? ((id) => NodeFSP.readFile(`/proc/${id}/cmdline`, "utf8")) + )(pid).catch(() => ""); + if (!namesAgentBrowser(commandLine)) return "not-agent-browser"; + (seams.terminate ?? ((id) => process.kill(id, "SIGTERM")))(pid); + return "stopped"; +} + +/** Calls `changed` whenever something in one of the directories changes. + * A directory that does not exist yet is watched once it does: `retry` is + * called on the periodic read. The Platform's `watchHolds`. */ +export function watchDirectories( + directories: readonly (string | undefined)[], + changed: () => void, +): Readonly<{ retry: () => void; stop: () => void }> { + const watchers = new Map(); + const retry = () => { + for (const directory of directories) { + if (directory === undefined || watchers.has(directory)) continue; + try { + const watcher = NodeFS.watch(directory, { persistent: false }, () => changed()); + watcher.on("error", () => { + watcher.close(); + watchers.delete(directory); + }); + watchers.set(directory, watcher); + } catch {} + } + }; + retry(); + return Object.freeze({ + retry, + stop: () => { + for (const watcher of watchers.values()) watcher.close(); + watchers.clear(); + }, + }); +} diff --git a/apps/server/src/lazurio/overlay.ts b/apps/server/src/lazurio/overlay.ts new file mode 100644 index 000000000000..5f5d7bfabce6 --- /dev/null +++ b/apps/server/src/lazurio/overlay.ts @@ -0,0 +1,18 @@ +/** + * Lazurio overlay (root decision 0191, plan DEV-6646): the server overlay's one layer, which one + * line of `server.ts` joins into upstream's routes. The Environment browser host drives the + * Environment browser for T3's browser tools (environmentBrowser.ts); each thread keeps its tabs + * of that browser in its holder file (threadTabs.ts), which the web reaches through + * threadTabsRoute.ts, and which reads the threads through threadLifecycle.ts. + */ + +import * as Layer from "effect/Layer"; + +import * as EnvironmentBrowser from "./environmentBrowser.ts"; +import * as ThreadLifecycle from "./threadLifecycle.ts"; +import * as ThreadTabs from "./threadTabs.ts"; +import * as ThreadTabsRoute from "./threadTabsRoute.ts"; + +export const layer = Layer.mergeAll(EnvironmentBrowser.layer, ThreadTabsRoute.layer).pipe( + Layer.provide(ThreadTabs.layer.pipe(Layer.provide(ThreadLifecycle.layer))), +); diff --git a/apps/server/src/lazurio/threadLifecycle.ts b/apps/server/src/lazurio/threadLifecycle.ts new file mode 100644 index 000000000000..48f582249a87 --- /dev/null +++ b/apps/server/src/lazurio/threadLifecycle.ts @@ -0,0 +1,61 @@ +/** + * Lazurio overlay (plan DEV-6646, task 501): what the threads' tabs need of T3 Code's + * orchestration, behind one adapter, so a port to another orchestration rewrites only this file. + * The live threads are those the projection shows: neither archived nor deleted. + */ + +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; + +/** A live thread: its id and title. */ +export interface LiveThread { + readonly id: ThreadId; + readonly title: string; +} + +/** The projection could not be read. */ +export class ThreadLifecycleReadError extends Schema.TaggedError()( + "ThreadLifecycleReadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "The threads could not be read."; + } +} + +export class ThreadLifecycle extends Context.Service< + ThreadLifecycle, + { + /** The live thread with this id, or none for an unknown, archived or deleted one. */ + readonly liveThread: ( + threadId: ThreadId, + ) => Effect.Effect, ThreadLifecycleReadError>; + /** Every live thread. */ + readonly liveThreads: Effect.Effect, ThreadLifecycleReadError>; + } +>()("t3/lazurio/threadLifecycle") {} + +const make = Effect.gen(function* () { + const projection = yield* ProjectionSnapshotQuery; + return ThreadLifecycle.of({ + liveThread: (threadId) => + projection.getThreadShellById(threadId).pipe( + Effect.map(Option.map((thread) => ({ id: thread.id, title: thread.title }))), + Effect.mapError((cause) => new ThreadLifecycleReadError({ cause })), + ), + liveThreads: projection.getShellSnapshot().pipe( + Effect.map((snapshot) => + snapshot.threads.map((thread) => ({ id: thread.id, title: thread.title })), + ), + Effect.mapError((cause) => new ThreadLifecycleReadError({ cause })), + ), + }); +}); + +export const layer = Layer.effect(ThreadLifecycle, make); diff --git a/apps/server/src/lazurio/threadTabs.test.ts b/apps/server/src/lazurio/threadTabs.test.ts new file mode 100644 index 000000000000..ff0e28d26415 --- /dev/null +++ b/apps/server/src/lazurio/threadTabs.test.ts @@ -0,0 +1,572 @@ +// @effect-diagnostics nodeBuiltinImport:off - the holder files are real files in a temporary runtime directory. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { agentBrowserSessionName } from "./agentBrowserSession.ts"; +import { + type BootTime, + holderDirectory, + holderFileText, + liveTabsPath, + parseHolderRecords, + replaceOwnerFile, +} from "./holders.ts"; +import { + createThreadTabs, + MAX_THREAD_TABS, + type ThreadTabsEvent, + type ThreadTabsSnapshot, +} from "./threadTabs.ts"; + +// A thread's tabs live in its holder file and stay true to the browser by the hub's list +// (LazurioPlatform F47 point 4). The reconciliation cases are F47's, named as its step 2 names +// them for the Launchpad (tests/launchpad-new-tab.test.ts), here for T3 Code's threads. + +const id = (n: number) => n.toString(16).toUpperCase().padStart(32, "0"); +const boot = "6f2c1d0e-1a2b-4c3d-8e4f-5a6b7c8d9e0f"; +const earlierBoot = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; +const at = (seconds: number, ofBoot: string = boot): BootTime => ({ boot: ofBoot, at: seconds }); +const threadA = "4a1f9c2e-7b3d-4e5f-8a6b-9c0d1e2f3a4b"; +const threadB = "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"; +const stripA = agentBrowserSessionName(threadA); +const stripB = agentBrowserSessionName(threadB); +const viewOrigin = "https://browser.vm-01.example.lazurio.io"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + vi.useRealTimers(); + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +async function world(options: { readonly declared?: boolean } = {}) { + const runtime = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "thread-tabs-")); + cleanups.push(() => NodeFSP.rm(runtime, { recursive: true, force: true })); + const holders = holderDirectory({ XDG_RUNTIME_DIR: runtime })!; + const listPath = liveTabsPath({ XDG_RUNTIME_DIR: runtime })!; + const sockets = NodePath.join(runtime, "agent-browser"); + let clock: BootTime | null = at(100); + const threads = new Map([ + [threadA, "Web"], + [threadB, "Docs"], + ]); + const listReads: number[] = []; + /** The fake process table: pid → command line. */ + const processes = new Map(); + const logs: string[] = []; + const watched: Array> = []; + let retries = 0; + let changed: () => void = () => undefined; + const make = () => + createThreadTabs({ + runtimeDirectory: runtime, + socketDirectory: sockets, + epoch: "epoch-1", + bootClock: async () => clock, + liveThread: async (threadId) => { + const title = threads.get(threadId); + return title === undefined ? null : { id: threadId as never, title }; + }, + liveThreads: async () => + [...threads].map(([threadId, title]) => ({ id: threadId as never, title })), + readFile: async (path) => { + if (path === listPath) listReads.push(listReads.length); + return NodeFSP.readFile(path, "utf8"); + }, + daemon: { alive: (pid) => processes.has(pid) }, + watch: (directories, onChange) => { + watched.push(directories); + changed = onChange; + return { + retry: () => { + retries += 1; + }, + stop: () => undefined, + }; + }, + everyMs: 60_000, + debounceMs: 30, + log: (event) => logs.push(event), + }); + const core = make(); + cleanups.push(() => core.stop()); + const events: ThreadTabsEvent[] = []; + const unsubscribe = core.subscribe(threadA, (event) => events.push(event)); + cleanups.push(async () => unsubscribe()); + const declare = async () => { + core.declare(viewOrigin); + await core.settled(); + }; + if (options.declared !== false) await declare(); + const fileOf = (strip: string) => NodePath.join(holders, `${strip}.json`); + return { + runtime, + holders, + listPath, + sockets, + core, + events, + threads, + processes, + logs, + watched, + listReads, + declare, + retries: () => retries, + changed: () => changed(), + fileOf, + /** Another T3 Code server on the same files, as after a restart. */ + restarted: async () => { + const again = make(); + cleanups.push(() => again.stop()); + again.declare(viewOrigin); + await again.settled(); + return again; + }, + setClock: (next: BootTime | null) => { + clock = next; + }, + /** The hub's snapshot of the browser's live tabs. */ + list: (tabs: readonly string[], takenAt: BootTime) => + replaceOwnerFile(listPath, `${JSON.stringify({ tabs, takenAt })}\n`), + stored: async (strip = stripA) => { + const text = await NodeFSP.readFile(fileOf(strip), "utf8").catch(() => null); + return text === null ? null : (parseHolderRecords(`${strip}.json`, text)?.tabs ?? "broken"); + }, + storeFile: async (strip: string, label: string, tabs: Parameters[2]) => { + await NodeFSP.mkdir(holders, { recursive: true, mode: 0o700 }); + await NodeFSP.writeFile(fileOf(strip), holderFileText(strip, label, tabs), { mode: 0o600 }); + }, + bind: async (session: string, targetId: string, pid?: number) => { + await NodeFSP.mkdir(sockets, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sockets, `${session}.target`), + JSON.stringify({ targetId }), + ); + if (pid !== undefined) { + await NodeFSP.writeFile(NodePath.join(sockets, `${session}.pid`), `${pid}\n`); + } + }, + lastSnapshot: (): ThreadTabsSnapshot | { available: false } | undefined => { + const snapshots = events.filter((event) => event.type === "snapshot"); + const last = snapshots.at(-1); + return last?.type === "snapshot" ? last.answer : undefined; + }, + }; +} + +const tabIds = (answer: unknown) => + (answer as ThreadTabsSnapshot | undefined)?.tabs.map((tab) => tab.tabId); + +describe("reconciliation (F47 point 4)", () => { + it("a tab closed for everyone, or ended, while no client shows it goes with the hub's next list", async () => { + const w = await world(); + await w.list([], at(90)); + await w.core.hold(threadA, id(1)); + await w.core.hold(threadA, id(2)); + expect(tabIds(w.lastSnapshot())).toEqual([id(1), id(2)]); + // The hub's snapshot after the tab ended; no device shows it. + await w.list([id(2)], at(110)); + await w.core.refresh(); + expect(await w.stored()).toEqual([{ id: id(2), recordedAt: at(100) }]); + // The file is rewritten and the subscribers hear it. + expect(tabIds(w.lastSnapshot())).toEqual([id(2)]); + }); + + it("after a browser restart the list of new ids drops every old one, in every thread's file", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + w.setClock(at(101)); + await w.core.hold(threadB, id(2)); + await w.list([id(9)], at(120)); + // A hold reconciles before it writes: the new tab is the only one left. + w.setClock(at(121)); + await w.core.hold(threadA, id(9)); + expect(await w.stored()).toEqual([{ id: id(9), recordedAt: at(121) }]); + await w.core.refresh(); + expect(await w.stored(stripB)).toBeNull(); + }); + + it("a server that starts with stored ids that ended meanwhile drops them; one without a time counts as old", async () => { + const w = await world({ declared: false }); + await w.storeFile(stripA, "Web", [ + { id: id(1), recordedAt: at(50) }, + { id: id(2), recordedAt: at(50) }, + { id: id(3), recordedAt: null }, + ]); + await w.list([id(2)], at(100)); + await w.declare(); + expect(await w.stored()).toEqual([{ id: id(2), recordedAt: at(50) }]); + expect(tabIds(await w.core.read(threadA))).toEqual([id(2)]); + }); + + it("a tab recorded before the list has it stays", async () => { + const w = await world(); + // The snapshot began at 100, before the tab existed. + await w.list([], at(100)); + w.setClock(at(101)); + await w.core.hold(threadA, id(1)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + // The hub's next snapshot has it. + await w.list([id(1)], at(130)); + await w.core.refresh(); + expect(await w.stored()).toEqual([{ id: id(1), recordedAt: at(101) }]); + }); + + it("a live tab whose event reached the hub late is in the snapshot, so it stays", async () => { + const w = await world(); + await w.core.hold(threadA, id(7)); + // A list begun before the record that lacks it never drops it. + await w.list([], at(99)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(7)]); + // The hub never heard of the tab; its snapshot asked the browser itself. + await w.list([id(7)], at(110)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(7)]); + }); + + it("a tab that ended before T3 Code recorded it goes only with a list of the same boot taken after the record", async () => { + const w = await world(); + // It ended at 99; the hub's snapshot at 99.5 lacks it. + await w.list([], at(99.5)); + await w.core.hold(threadA, id(6)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(6)]); + // A list of another boot says nothing about this one. + await w.list([], at(500, earlierBoot)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(6)]); + // The periodic snapshot 30 s later began after the record. + await w.list([], at(130)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([]); + expect(await w.stored()).toBeNull(); + }); + + it("a record from an earlier boot goes when this boot's list lacks it", async () => { + const w = await world({ declared: false }); + await w.storeFile(stripA, "Web", [ + { id: id(1), recordedAt: at(5_000, earlierBoot) }, + { id: id(2), recordedAt: at(5_000, earlierBoot) }, + ]); + // Seconds since this boot are fewer than the earlier record's. + await w.list([id(2)], at(10)); + await w.declare(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(2)]); + }); + + it("a list of another boot is ignored, and so is a missing or unreadable list", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + await w.list([], at(5_000, earlierBoot)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + await NodeFSP.rm(w.listPath); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + await NodeFSP.writeFile(w.listPath, "{", { mode: 0o600 }); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + // Without the boot clock (not Linux) nothing is dropped either. + await w.list([], at(500)); + w.setClock(null); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + }); + + it("a step of the wall clock, back or forward by an hour, changes no decision", async () => { + const w = await world(); + await w.list([], at(100)); + const realNow = Date.now; + try { + // The wall clock steps back an hour after the snapshot began; the boot clock reads the + // record as later than it all the same. + Date.now = () => realNow() - 3_600_000; + w.setClock(at(100.4)); + await w.core.hold(threadA, id(7)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(7)]); + // And forward an hour: a snapshot begun before the record still drops nothing. + Date.now = () => realNow() + 3_600_000; + await w.list([], at(100.2)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(7)]); + } finally { + Date.now = realNow; + } + expect(await w.stored()).toEqual([{ id: id(7), recordedAt: at(100.4) }]); + }); +}); + +describe("writes re-read the file and fail closed", () => { + it("every write starts from the file on disk", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + // Another T3 Code process on the same file, for a moment during an update. + await w.storeFile(stripA, "Web", [{ id: id(5), recordedAt: at(99) }]); + w.setClock(at(102)); + await w.core.hold(threadA, id(2)); + expect(await w.stored()).toEqual([ + { id: id(5), recordedAt: at(99) }, + { id: id(2), recordedAt: at(102) }, + ]); + }); + + it("a file that breaks after a good read is never written over, until a read succeeds", async () => { + const w = await world(); + w.setClock(at(10)); + await w.core.hold(threadA, id(1)); + // A list taken after the record that lacks id(1): a reconciliation would drop it. + await w.list([], at(20)); + await NodeFSP.writeFile(w.fileOf(stripA), "{"); + await w.core.refresh(); + expect(await NodeFSP.readFile(w.fileOf(stripA), "utf8")).toBe("{"); + expect(w.logs).toContain("thread-tabs-reconcile-failed"); + w.setClock(at(30)); + await expect(w.core.hold(threadA, id(2))).rejects.toMatchObject({ + reason: "holder-unreadable", + }); + await expect(w.core.release(threadA, id(1))).rejects.toMatchObject({ + reason: "holder-unreadable", + }); + expect(await NodeFSP.readFile(w.fileOf(stripA), "utf8")).toBe("{"); + // The strip keeps the last good index meanwhile. + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + // Unreadable instead of broken: the same. + await w.storeFile(stripA, "Web", [{ id: id(1), recordedAt: at(10) }]); + await NodeFSP.chmod(w.fileOf(stripA), 0o000); + try { + await w.core.refresh(); + await expect(w.core.hold(threadA, id(2))).rejects.toMatchObject({ + reason: "holder-unreadable", + }); + } finally { + await NodeFSP.chmod(w.fileOf(stripA), 0o600); + } + expect(await w.stored()).toEqual([{ id: id(1), recordedAt: at(10) }]); + // Readable again: the next operation reads it, drops id(1) by the list and takes the hold. + await w.core.hold(threadA, id(2)); + expect(await w.stored()).toEqual([{ id: id(2), recordedAt: at(30) }]); + }); + + it("a burst of list changes is one reconciliation", async () => { + const w = await world(); + await w.list([id(1)], at(90)); + await w.core.hold(threadA, id(1)); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const before = w.listReads.length; + for (let n = 0; n < 5; n++) w.changed(); + await vi.advanceTimersByTimeAsync(30); + await w.core.settled(); + expect(w.listReads.length - before).toBe(1); + // The watch covers the hub's directory, the holder files and the agents' bindings. + expect(w.watched).toEqual([ + [NodePath.join(w.runtime, "lazurio-browser"), w.holders, w.sockets], + ]); + }); + + it("the beat reads the list again and retries the watch; a file is rewritten only when a hold went", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const w = await world(); + await w.list([id(1), id(2)], at(90)); + await w.core.hold(threadA, id(1)); + await w.core.hold(threadA, id(2)); + const written = (await NodeFSP.stat(w.fileOf(stripA))).ino; + const reads = w.listReads.length; + await vi.advanceTimersByTimeAsync(60_000); + await w.core.settled(); + expect(w.retries()).toBe(1); + expect(w.listReads.length).toBeGreaterThan(reads); + // Nothing went: the file is the same one. + expect((await NodeFSP.stat(w.fileOf(stripA))).ino).toBe(written); + await w.list([id(2)], at(110)); + await vi.advanceTimersByTimeAsync(60_000); + await w.core.settled(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(2)]); + }); +}); + +describe("holds and releases", () => { + it("a new id gets the boot clock's time; a held id keeps its time and writes nothing", async () => { + const w = await world(); + w.setClock(at(100.25)); + await w.core.hold(threadA, id(1).toLowerCase()); + const written = (await NodeFSP.stat(w.fileOf(stripA))).ino; + w.setClock(at(200)); + await w.core.hold(threadA, id(1)); + expect(await w.stored()).toEqual([{ id: id(1), recordedAt: at(100.25) }]); + expect((await NodeFSP.stat(w.fileOf(stripA))).ino).toBe(written); + // The file is the hub's holder file, labelled with the thread's title. + expect(JSON.parse(await NodeFSP.readFile(w.fileOf(stripA), "utf8"))).toMatchObject({ + strip: stripA, + label: "Web", + tabs: [id(1)], + }); + }); + + it("the 129th hold is refused and none is dropped for it", async () => { + const w = await world(); + w.threads.set(threadA, "ž".repeat(300)); + for (let n = 1; n <= MAX_THREAD_TABS; n++) await w.core.hold(threadA, id(n)); + await expect(w.core.hold(threadA, id(MAX_THREAD_TABS + 1))).rejects.toMatchObject({ + reason: "too-many-tabs", + }); + expect((await w.stored())?.length).toBe(MAX_THREAD_TABS); + const text = await NodeFSP.readFile(w.fileOf(stripA), "utf8"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(20 * 1024); + // The label is cut at 200 characters. + expect((JSON.parse(text) as { label: string }).label).toHaveLength(200); + }); + + it("an unknown thread takes no hold", async () => { + const w = await world(); + await expect(w.core.hold("5c4b3a29-1807-4f6e-9d5c-4b3a2918f7e6", id(1))).rejects.toMatchObject({ + reason: "thread-not-found", + }); + }); + + it("a release of the last id removes the file; a tab not held is released already", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + expect(tabIds(await w.core.release(threadA, id(1)))).toEqual([]); + expect(await w.stored()).toBeNull(); + expect(tabIds(await w.core.release(threadA, id(3)))).toEqual([]); + expect(await w.stored()).toBeNull(); + await expect(w.core.release(threadA, "nope")).rejects.toMatchObject({ + reason: "invalid-request", + }); + }); +}); + +describe("tabs an agent opens", () => { + it("preview_open records with a reveal, open: false without", async () => { + const w = await world(); + await w.core.record(threadA, id(1), true); + await w.core.record(threadA, id(2), false); + const snapshot = (await w.core.read(threadA)) as ThreadTabsSnapshot; + expect(snapshot.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id])).toEqual([ + [id(1), "epoch-1:1"], + [id(2), undefined], + ]); + // A later reveal of the same tab replaces the request. + await w.core.record(threadA, id(1), true); + expect(((await w.core.read(threadA)) as ThreadTabsSnapshot).tabs[0]?.revealRequest).toEqual({ + id: "epoch-1:2", + }); + }); + + it("a new binding of the thread's session records with a reveal; one present at the start does not", async () => { + const w = await world({ declared: false }); + await w.bind(stripB, id(8)); + await w.declare(); + expect(await w.stored(stripB)).toBeNull(); + await w.bind(stripA, id(3)); + await w.core.refresh(); + const snapshot = (await w.core.read(threadA)) as ThreadTabsSnapshot; + expect(snapshot.tabs.map((tab) => tab.tabId)).toEqual([id(3)]); + expect(snapshot.tabs[0]?.revealRequest).toBeDefined(); + }); + + it("a binding unchanged after a release is not recorded again", async () => { + const w = await world(); + await w.bind(stripA, id(3)); + await w.core.refresh(); + await w.core.release(threadA, id(3)); + await w.core.refresh(); + expect(await w.stored()).toBeNull(); + }); + + it("another Environment's tab goes to the thread's subscribers only, live", async () => { + const w = await world(); + const other: ThreadTabsEvent[] = []; + const unsubscribe = w.core.subscribe(threadB, (event) => other.push(event)); + cleanups.push(async () => unsubscribe()); + const view = `https://browser.vm-02.acme.lazurio.io/t/${id(9).toLowerCase()}`; + w.core.foreign(threadA, { tabId: id(9).toLowerCase(), view }, true); + expect(w.events.at(-1)).toEqual({ + type: "foreign", + tab: { tabId: id(9), view, revealRequest: { id: "epoch-1:1" } }, + }); + expect(other).toEqual([]); + }); +}); + +describe("what a snapshot says of each tab", () => { + it("names Apps and other threads that hold it too, and leaves out the thread's own strip", async () => { + const w = await world(); + await w.storeFile("launchpad", "Launchpad", [{ id: id(1), recordedAt: at(90) }]); + await w.core.hold(threadB, id(1)); + await w.core.hold(threadA, id(1)); + await w.core.refresh(); + const [tab] = ((await w.core.read(threadA)) as ThreadTabsSnapshot).tabs; + expect(tab).toEqual({ + tabId: id(1), + view: `${viewOrigin}/t/${id(1)}`, + agent: false, + alsoHeldBy: [ + { strip: "launchpad", label: "Launchpad" }, + { strip: stripB, label: "Docs" }, + ], + }); + // Another app's file that cannot be read only leaves its own part out. + await NodeFSP.writeFile(NodePath.join(w.holders, "mausbot.json"), "{"); + await w.core.refresh(); + expect(((await w.core.read(threadA)) as ThreadTabsSnapshot).tabs[0]?.alsoHeldBy).toHaveLength( + 2, + ); + }); + + it("says an agent works in a tab while a session bound to it has a live daemon", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + await w.bind("codex-1", id(1), 4242); + w.processes.set(4242, "agent-browser\0"); + await w.core.refresh(); + expect(((await w.core.read(threadA)) as ThreadTabsSnapshot).tabs[0]?.agent).toBe(true); + expect((w.lastSnapshot() as ThreadTabsSnapshot).tabs[0]?.agent).toBe(true); + w.processes.delete(4242); + await w.core.refresh(); + expect(((await w.core.read(threadA)) as ThreadTabsSnapshot).tabs[0]?.agent).toBe(false); + }); + + it("carries a revision that grows with every change of it, streamed or not", async () => { + const w = await world(); + // Nobody streams thread B: its answers still order by their revisions. + const first = await w.core.hold(threadB, id(1)); + expect(((await w.core.read(threadB)) as ThreadTabsSnapshot).revision).toBe(first.revision); + const second = await w.core.hold(threadB, id(2)); + expect(second.revision).toBeGreaterThan(first.revision); + // Another thread's change leaves it as it is. + await w.core.hold(threadA, id(3)); + expect(((await w.core.read(threadB)) as ThreadTabsSnapshot).revision).toBe(second.revision); + expect((await w.core.release(threadB, id(1))).revision).toBeGreaterThan(second.revision); + // A streamed thread's event and the answer of the same change agree. + const held = await w.core.hold(threadA, id(4)); + expect(w.lastSnapshot()).toMatchObject({ revision: held.revision, epoch: "epoch-1" }); + }); +}); + +describe("when something is missing", () => { + it("without the Environment's declaration the service is unavailable and writes nothing", async () => { + const w = await world({ declared: false }); + expect(await w.core.read(threadA)).toEqual({ available: false }); + await expect(w.core.hold(threadA, id(1))).rejects.toMatchObject({ reason: "unavailable" }); + await w.core.record(threadA, id(1), true); + expect(await NodeFSP.readdir(w.runtime)).toEqual([]); + // Declared later: the subscribers hear the snapshot. + await w.declare(); + expect(w.lastSnapshot()).toMatchObject({ available: true, tabs: [] }); + w.core.declare(undefined); + expect(w.lastSnapshot()).toEqual({ available: false }); + }); + + it("without the hub's list nothing is dropped", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + await w.core.refresh(); + expect(tabIds(await w.core.read(threadA))).toEqual([id(1)]); + }); +}); diff --git a/apps/server/src/lazurio/threadTabs.ts b/apps/server/src/lazurio/threadTabs.ts new file mode 100644 index 000000000000..76aa8155fb72 --- /dev/null +++ b/apps/server/src/lazurio/threadTabs.ts @@ -0,0 +1,796 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - The holder files, their watches and their beat live outside the Effect runtime, as the Launchpad's do. + +/** + * Lazurio overlay (plan DEV-6646, task 501; LazurioPlatform decision F47 points 4, 5 and 10): the + * tabs of the Environment browser that each T3 Code thread keeps. + * + * A thread's tabs live only in its holder file, `holders/t3-.json` (holders.ts), the + * file the view's hub reads to keep them. Beside the ids it keeps each tab's record time on the + * boot clock (`recordedAt`), so a restart of T3 Code finds them intact; T3 Code's database stays + * upstream's. The server keeps only an index of the files, rebuilt when it starts, and what never + * has to outlive it: the reveal requests, the other holders' labels and which tabs an agent works + * in. + * + * Holds stay true to the browser the Launchpad's way (LazurioPlatform + * `src/launchpad/new-tab/holder.ts`): every file is reconciled with the hub's list of live tabs + * (`tabs.json`) when the service starts, when the list changes (watched, a burst joined into one), + * every 30 s, and before every write, dropping exactly what `keptTabs` lets go. Every write + * re-reads its file first, and a file that cannot be read is never written over: its holds stay + * as the file has them. One queue serializes every write. Nothing here reads the wall clock. + * + * A tab enters a thread's strip when a person holds it (the web, threadTabsRoute.ts), when + * preview_open opens or adopts it (environmentBrowser.ts `adopt`), and when the thread's own + * agent-browser session binds another tab (`t3-.target`, watched as the hub watches it). + * The service is off, answering `{available: false}`, without an absolute XDG_RUNTIME_DIR or + * while the Environment does not declare its browser (the Environment browser host's declaration). + */ + +import { ThreadId } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { agentBrowserSessionName } from "./agentBrowserSession.ts"; +import { + agentBrowserSocketDirectory, + type BootTime, + type DaemonSeams, + directoryNames, + fileText, + holderDirectory, + holderFileText, + isMissing, + isStrip, + isTargetId, + keptTabs, + liveTabsPath, + parseHolder, + parseHolderRecords, + parseLiveTabs, + processAlive, + readBootClock, + readSessionBindings, + replaceOwnerFile, + type SessionBinding, + watchDirectories, +} from "./holders.ts"; +import { type LiveThread, ThreadLifecycle } from "./threadLifecycle.ts"; + +/** At most this many tabs per thread: a hold past it is refused, never one dropped to make room. */ +export const MAX_THREAD_TABS = 128; +/** A defensive bound well inside the 64 KiB the hub reads of a holder file. */ +const MAX_FILE_BYTES = 60 * 1024; +const MAX_LABEL_LENGTH = 200; + +const REASONS = [ + "invalid-request", + "thread-not-found", + "too-many-tabs", + "unavailable", + "holder-unreadable", + "hold-failed", +] as const; +export type ThreadTabsErrorReason = (typeof REASONS)[number]; + +/** Why a hold, a release or a read is refused; the route maps each reason to its status. */ +export class ThreadTabsError extends Schema.TaggedError()("ThreadTabsError", { + reason: Schema.Literals(REASONS), +}) { + override get message(): string { + return `The thread's tabs are not available (${this.reason}).`; + } +} + +/** Another strip that holds a tab: a T3 Code thread (`t3-…`), the Launchpad (`launchpad`), … */ +export interface TabHolder { + readonly strip: string; + readonly label: string; +} + +export interface ThreadTab { + readonly tabId: string; + /** The view of the tab, `/t/`. */ + readonly view: string; + /** An agent works in it: a session bound to it has a live daemon. */ + readonly agent: boolean; + readonly alsoHeldBy: ReadonlyArray; + /** Set when an agent recorded the tab and asked to show it, replaced by its next reveal. */ + readonly revealRequest?: { readonly id: string }; +} + +export interface ThreadTabsSnapshot { + readonly available: true; + /** This run of the server: revisions start again in another. */ + readonly epoch: string; + /** Grows with every change of the thread's snapshot; an equal revision is an equal snapshot. */ + readonly revision: number; + readonly threadId: string; + readonly strip: string; + readonly viewOrigin: string; + /** In the holder file's order. */ + readonly tabs: ReadonlyArray; +} + +export type ThreadTabsAnswer = { readonly available: false } | ThreadTabsSnapshot; + +/** Another Environment's tab preview_open answered with: the thread's devices show it, live only. */ +export interface ForeignTab { + readonly tabId: string; + readonly view: string; + readonly revealRequest?: { readonly id: string }; +} + +export type ThreadTabsEvent = + | { readonly type: "snapshot"; readonly answer: ThreadTabsAnswer } + | { readonly type: "foreign"; readonly tab: ForeignTab }; + +export interface ThreadTabsCoreSeams { + /** An absolute XDG_RUNTIME_DIR: `lazurio-browser/` and its `holders/` are inside. */ + readonly runtimeDirectory: string; + /** agent-browser's socket directory, or undefined without one. */ + readonly socketDirectory: string | undefined; + /** This server's run: the prefix of every reveal request. */ + readonly epoch: string; + /** The boot clock (`readBootClock`); null off Linux. */ + readonly bootClock: () => Promise; + /** The live thread with this id, or null; rejects when it cannot be read. */ + readonly liveThread: (threadId: string) => Promise; + /** Every live thread; rejects when they cannot be read. */ + readonly liveThreads: () => Promise>; + readonly readFile?: ((path: string) => Promise) | undefined; + readonly daemon?: DaemonSeams | undefined; + readonly watch?: typeof watchDirectories | undefined; + /** The beat (30 s) and the wait that joins a burst of changes (100 ms). */ + readonly everyMs?: number | undefined; + readonly debounceMs?: number | undefined; + readonly log?: ((event: string, detail?: Readonly>) => void) | undefined; +} + +export interface ThreadTabsCore { + /** The Environment's declaration of its browser: the origin of its view, or undefined. */ + readonly declare: (viewOrigin: string | undefined) => void; + readonly read: (threadId: string) => Promise; + readonly hold: (threadId: string, tabId: string) => Promise; + readonly release: (threadId: string, tabId: string) => Promise; + /** An agent's tab joins the thread's strip (preview_open, a new binding); never fails. */ + readonly record: (threadId: string, tabId: string, reveal: boolean) => Promise; + readonly foreign: ( + threadId: string, + tab: { readonly tabId: string; readonly view: string }, + reveal: boolean, + ) => void; + readonly subscribe: (threadId: string, listener: (event: ThreadTabsEvent) => void) => () => void; + /** Reconciles, reads the other holders and the agents' bindings now (what the beat does). */ + readonly refresh: () => Promise; + /** Settles once the start and every operation queued so far are done. */ + readonly settled: () => Promise; + readonly stop: () => Promise; +} + +interface Held { + label: string; + /** Tab → its record time, in the order first recorded. */ + readonly tabs: Map; +} + +const isThreadTabsError = Schema.is(ThreadTabsError); +const isThreadStrip = (strip: string) => isStrip(strip) && strip.startsWith("t3-"); +const refusal = (reason: ThreadTabsErrorReason) => new ThreadTabsError({ reason }); +const cutLabel = (label: string) => label.trim().slice(0, MAX_LABEL_LENGTH); + +export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { + const lazurioDirectory = NodePath.join(seams.runtimeDirectory, "lazurio-browser"); + const holdersDirectory = holderDirectory({ XDG_RUNTIME_DIR: seams.runtimeDirectory })!; + const listPath = liveTabsPath({ XDG_RUNTIME_DIR: seams.runtimeDirectory })!; + const read = seams.readFile ?? ((path: string) => NodeFSP.readFile(path, "utf8")); + const alive = seams.daemon?.alive ?? processAlive; + const log = seams.log ?? (() => undefined); + const everyMs = seams.everyMs ?? 30_000; + const debounceMs = seams.debounceMs ?? 100; + + /** The last good read of each of T3 Code's holder files, by strip. */ + const index = new Map(); + const reveals = new Map>(); + /** Every holder file's holds, by tab: the other holders a snapshot names. */ + let holdsOf = new Map(); + /** Tabs a session with a live daemon is bound to. */ + let working = new Set(); + /** The tab each `t3-` session was last seen bound to: only another one is recorded. */ + const lastBound = new Map(); + const threadOfSessionCache = new Map(); + const subscribers = new Map< + string, + Set<{ readonly threadId: string; readonly listener: (event: ThreadTabsEvent) => void }> + >(); + /** Each strip's last snapshot and its revision. A snapshot that changed gets the next revision and + * an unchanged one keeps its own, whether or not a client streams the strip, so a client orders + * every snapshot it gets (a POST's answer against the stream) by its revision alone. */ + const stamps = new Map(); + /** What each streamed strip was last sent: its revision, or unavailable. */ + const lastSent = new Map(); + let viewOrigin: string | undefined; + let revision = 0; + let revealCount = 0; + let startup: Promise | undefined; + /** The start read every file: until then no snapshot goes out, as an empty index would empty + * every device's strip. */ + let started = false; + let stopped = false; + let queue: Promise = Promise.resolve(); + /** Refreshes the debounce and the beat started, so `settled` waits for them too. */ + const running = new Set>(); + let beat: ReturnType | null = null; + let soon: ReturnType | null = null; + let watcher: ReturnType | null = null; + + const available = () => viewOrigin !== undefined; + const stripOf = (threadId: string) => agentBrowserSessionName(threadId); + const holderPath = (strip: string) => NodePath.join(holdersDirectory, `${strip}.json`); + + /** One operation at a time, so a write never carries an older state than the one before it. */ + const serialized = (work: () => Promise): Promise => { + const next = queue.then(work, work); + queue = next.catch(() => undefined); + return next; + }; + + /** The file as it is now: only a file that does not exist holds nothing. One that cannot be read, + * or is not a holder file, throws, so nothing is written over it until a read succeeds. */ + async function current(strip: string): Promise { + let text: string; + try { + text = await read(holderPath(strip)); + } catch (error) { + if (isMissing(error)) return { label: "", tabs: new Map() }; + throw refusal("holder-unreadable"); + } + const file = parseHolderRecords(`${strip}.json`, text); + if (file === null) throw refusal("holder-unreadable"); + return { label: file.label, tabs: new Map(file.tabs.map((tab) => [tab.id, tab.recordedAt])) }; + } + + /** The hub's list and the current boot, read for one reconciliation. */ + async function listAndBoot() { + const text = await read(listPath).catch(() => null); + const list = text === null ? null : parseLiveTabs(text); + const boot = list === null ? null : ((await seams.bootClock())?.boot ?? null); + return { list, boot }; + } + + /** The ids of `held` the hub's list lets go (`keptTabs`). */ + function letGo( + held: Held, + { list, boot }: Awaited>, + ): readonly string[] { + if (held.tabs.size === 0) return []; + const kept = new Set( + keptTabs( + [...held.tabs].map(([id, recordedAt]) => ({ id, recordedAt })), + list, + boot, + ), + ); + return [...held.tabs.keys()].filter((id) => !kept.has(id)); + } + + /** Writes the file, or removes it once its last tab went, so the hub reads fewer files. */ + async function write(strip: string, held: Held): Promise { + try { + if (held.tabs.size === 0) { + await NodeFSP.rm(holderPath(strip), { force: true }); + return; + } + const text = holderFileText( + strip, + held.label, + [...held.tabs].map(([id, recordedAt]) => ({ id, recordedAt })), + ); + if (Buffer.byteLength(text, "utf8") > MAX_FILE_BYTES) throw new Error("holder file too big"); + await replaceOwnerFile(holderPath(strip), text); + } catch { + throw refusal("hold-failed"); + } + } + + const forgetReveals = (strip: string, tabs: Iterable) => { + const own = reveals.get(strip); + if (own === undefined) return; + for (const tab of tabs) own.delete(tab); + if (own.size === 0) reveals.delete(strip); + }; + const remember = (strip: string, held: Held) => { + if (held.tabs.size === 0) index.delete(strip); + else index.set(strip, held); + }; + + /** Holds `tab` for `strip`, reconciling first; a tab held already keeps its time. */ + const holdTab = (strip: string, title: string, tab: string) => + serialized(async () => { + const held = await current(strip); + const gone = letGo(held, await listAndBoot()); + for (const id of gone) held.tabs.delete(id); + let changed = gone.length > 0; + if (!held.tabs.has(tab)) { + if (held.tabs.size >= MAX_THREAD_TABS) throw refusal("too-many-tabs"); + held.tabs.set(tab, await seams.bootClock()); + changed = true; + } + if (held.label === "") held.label = cutLabel(title); + if (changed) await write(strip, held); + forgetReveals(strip, gone); + remember(strip, held); + }); + + /** Lets go of `tab` for `strip`; a tab not held is released already. */ + const releaseTab = (strip: string, tab: string) => + serialized(async () => { + const held = await current(strip); + const gone = letGo(held, await listAndBoot()); + for (const id of gone) held.tabs.delete(id); + const had = held.tabs.delete(tab); + if (had || gone.length > 0) await write(strip, held); + forgetReveals(strip, [...gone, tab]); + remember(strip, held); + }); + + /** Every one of T3 Code's holder files against the hub's list. Never fails: a file that cannot be + * read is left as it is, and so is its last good index. */ + async function reconcileAll(): Promise { + let names: string[]; + try { + names = await directoryNames(holdersDirectory); + } catch { + log("thread-tabs-reconcile-failed", { reason: "holders-unreadable" }); + return; + } + const strips = names + .filter((name) => name.endsWith(".json")) + .map((name) => name.slice(0, -".json".length)) + .filter(isThreadStrip); + const listed = await listAndBoot(); + for (const strip of strips) { + try { + const held = await current(strip); + const gone = letGo(held, listed); + for (const id of gone) held.tabs.delete(id); + if (gone.length > 0) await write(strip, held); + forgetReveals(strip, gone); + remember(strip, held); + } catch { + log("thread-tabs-reconcile-failed", { strip }); + } + } + const present = new Set(strips); + for (const strip of index.keys()) if (!present.has(strip)) index.delete(strip); + } + + /** Every holder file's holds, for the other holders a snapshot names. An unreadable file of + * another app only leaves its part out. */ + async function readHolders(): Promise { + const holds = new Map(); + for (const name of await directoryNames(holdersDirectory).catch(() => [])) { + if (!name.endsWith(".json") || !isStrip(name.slice(0, -".json".length))) continue; + const text = await fileText(NodePath.join(holdersDirectory, name), read).catch(() => null); + const hold = text === null ? null : parseHolder(name, text); + if (hold === null) continue; + for (const id of hold.tabs) { + const entry = { strip: hold.strip, label: hold.label }; + const list = holds.get(id); + if (list === undefined) holds.set(id, [entry]); + else list.push(entry); + } + } + holdsOf = holds; + } + + /** The agents' bindings: which tabs an agent works in, and the `t3-` sessions bound to another + * tab since they were last seen (none on the first read, the baseline). */ + async function readBindings(baseline: boolean): Promise { + if (seams.socketDirectory === undefined) { + working = new Set(); + return []; + } + let bindings: SessionBinding[]; + try { + bindings = await readSessionBindings(seams.socketDirectory, alive, seams.readFile); + } catch { + return []; + } + working = new Set( + bindings.filter((binding) => binding.working).map((binding) => binding.targetId), + ); + const fresh: SessionBinding[] = []; + for (const binding of bindings) { + if (!isThreadStrip(binding.session)) continue; + const last = lastBound.get(binding.session); + lastBound.set(binding.session, binding.targetId); + if (!baseline && last !== binding.targetId) fresh.push(binding); + } + return fresh; + } + + /** The live thread whose session is `session`: its id when the name is the id unchanged, else + * by the names of the live threads. Looked up once. */ + async function threadOfSession(session: string): Promise { + if (!isThreadStrip(session)) return null; + const cached = threadOfSessionCache.get(session); + if (cached !== undefined) return cached; + const candidate = session.slice("t3-".length); + let threadId: string | null = null; + try { + if (agentBrowserSessionName(candidate) === session) { + threadId = (await seams.liveThread(candidate))?.id ?? null; + } else { + threadId = + (await seams.liveThreads()).find( + (thread) => agentBrowserSessionName(thread.id) === session, + )?.id ?? null; + } + } catch { + return null; + } + threadOfSessionCache.set(session, threadId); + return threadId; + } + + const nextRevealId = () => `${seams.epoch}:${++revealCount}`; + const reveal = (strip: string, tab: string) => { + const own = reveals.get(strip) ?? new Map(); + own.set(tab, nextRevealId()); + reveals.set(strip, own); + }; + + function snapshotBody(strip: string, origin: string) { + const own = reveals.get(strip); + return { + strip, + viewOrigin: origin, + tabs: [...(index.get(strip)?.tabs.keys() ?? [])].map((tabId): ThreadTab => { + const revealId = own?.get(tabId); + return { + tabId, + view: `${origin}/t/${tabId}`, + agent: working.has(tabId), + alsoHeldBy: (holdsOf.get(tabId) ?? []).filter((holder) => holder.strip !== strip), + ...(revealId === undefined ? {} : { revealRequest: { id: revealId } }), + }; + }), + }; + } + + /** The strip's snapshot now, with its revision (`stamps`). */ + function stamped(strip: string, origin: string) { + const body = snapshotBody(strip, origin); + const key = JSON.stringify(body); + let stamp = stamps.get(strip); + if (stamp?.key !== key) { + revision += 1; + stamp = { key, revision }; + stamps.set(strip, stamp); + } + return { revision: stamp.revision, body }; + } + + function snapshotFor(strip: string, threadId: string): ThreadTabsSnapshot { + if (viewOrigin === undefined) throw refusal("unavailable"); + const snapshot = stamped(strip, viewOrigin); + return { + available: true, + epoch: seams.epoch, + revision: snapshot.revision, + threadId, + ...snapshot.body, + }; + } + + /** Sends every subscribed strip whose snapshot changed its new snapshot. */ + function publish(): void { + if (viewOrigin !== undefined && !started) return; + for (const [strip, listeners] of subscribers) { + const snapshot = viewOrigin === undefined ? null : stamped(strip, viewOrigin); + const sent = snapshot === null ? "unavailable" : snapshot.revision; + if (lastSent.get(strip) === sent) continue; + lastSent.set(strip, sent); + for (const { threadId, listener } of listeners) { + listener({ + type: "snapshot", + answer: + snapshot === null + ? { available: false } + : { + available: true, + epoch: seams.epoch, + revision: snapshot.revision, + threadId, + ...snapshot.body, + }, + }); + } + } + } + + const loaded = async () => { + if (startup === undefined) throw refusal("unavailable"); + await startup; + }; + + async function liveThreadOf(threadId: string): Promise { + try { + return await seams.liveThread(threadId); + } catch { + throw refusal("unavailable"); + } + } + + async function record(threadId: string, tabId: string, revealed: boolean): Promise { + if (!available() || !isTargetId(tabId)) return; + try { + await loaded(); + const strip = stripOf(threadId); + const live = await seams.liveThread(threadId); + if (live === null) return; + const tab = tabId.toUpperCase(); + await holdTab(strip, live.title, tab); + if (revealed) reveal(strip, tab); + publish(); + } catch (error) { + log("thread-tabs-record-failed", { + reason: isThreadTabsError(error) ? error.reason : String(error), + }); + } + } + + /** The beat and a change: reconcile, read the other holders and the agents' bindings, then record + * the tabs the threads' own sessions newly bound. */ + async function refresh(): Promise { + if (stopped || !available() || startup === undefined) return; + await startup; + let fresh: SessionBinding[] = []; + try { + fresh = await serialized(async () => { + await reconcileAll(); + await readHolders(); + return readBindings(false); + }); + } catch (error) { + log("thread-tabs-refresh-failed", { cause: String(error) }); + } + for (const binding of fresh) { + const threadId = await threadOfSession(binding.session); + if (threadId !== null) await record(threadId, binding.targetId, true); + } + publish(); + } + + const track = (work: Promise) => { + running.add(work); + void work.finally(() => running.delete(work)); + }; + const refreshSoon = () => { + if (soon !== null || stopped) return; + soon = setTimeout(() => { + soon = null; + track(refresh()); + }, debounceMs); + }; + + async function start(): Promise { + if (stopped) return; + await serialized(async () => { + await reconcileAll(); + await readHolders(); + await readBindings(true); + }); + started = true; + if (stopped) return; + watcher = (seams.watch ?? watchDirectories)( + [lazurioDirectory, holdersDirectory, seams.socketDirectory], + refreshSoon, + ); + beat = setInterval(() => { + watcher?.retry(); + track(refresh()); + }, everyMs); + publish(); + } + + return { + declare(origin) { + viewOrigin = origin; + if (origin !== undefined && startup === undefined) { + startup = start().catch((error) => { + log("thread-tabs-start-failed", { cause: String(error) }); + }); + } + publish(); + }, + async read(threadId) { + if (!available()) return { available: false }; + await loaded(); + const strip = stripOf(threadId); + if ((await liveThreadOf(threadId)) === null) throw refusal("thread-not-found"); + return snapshotFor(strip, threadId); + }, + async hold(threadId, tabId) { + if (!isTargetId(tabId)) throw refusal("invalid-request"); + if (!available()) throw refusal("unavailable"); + await loaded(); + const strip = stripOf(threadId); + const live = await liveThreadOf(threadId); + if (live === null) throw refusal("thread-not-found"); + await holdTab(strip, live.title, tabId.toUpperCase()); + publish(); + return snapshotFor(strip, threadId); + }, + async release(threadId, tabId) { + if (!isTargetId(tabId)) throw refusal("invalid-request"); + if (!available()) throw refusal("unavailable"); + await loaded(); + const strip = stripOf(threadId); + await releaseTab(strip, tabId.toUpperCase()); + publish(); + return snapshotFor(strip, threadId); + }, + record, + foreign(threadId, tab, revealed) { + const event: ThreadTabsEvent = { + type: "foreign", + tab: { + tabId: tab.tabId.toUpperCase(), + view: tab.view, + ...(revealed ? { revealRequest: { id: nextRevealId() } } : {}), + }, + }; + for (const { listener } of subscribers.get(stripOf(threadId)) ?? []) listener(event); + }, + subscribe(threadId, listener) { + const strip = stripOf(threadId); + const entry = { threadId, listener }; + const listeners = subscribers.get(strip) ?? new Set(); + listeners.add(entry); + subscribers.set(strip, listeners); + return () => { + listeners.delete(entry); + if (listeners.size === 0) { + subscribers.delete(strip); + lastSent.delete(strip); + } + }; + }, + refresh, + async settled() { + await startup; + while (running.size > 0) await Promise.all(running); + await queue; + }, + async stop() { + stopped = true; + if (beat !== null) clearInterval(beat); + if (soon !== null) clearTimeout(soon); + beat = null; + soon = null; + watcher?.stop(); + watcher = null; + await queue; + }, + }; +} + +export class ThreadTabs extends Context.Service< + ThreadTabs, + { + /** The thread's snapshot, or `{available: false}` while the service is off. */ + readonly read: (threadId: ThreadId) => Effect.Effect; + readonly hold: ( + threadId: ThreadId, + tabId: string, + ) => Effect.Effect; + readonly release: ( + threadId: ThreadId, + tabId: string, + ) => Effect.Effect; + /** The thread's snapshot now and on every change, and another Environment's tabs, live. */ + readonly events: (threadId: ThreadId) => Stream.Stream; + readonly declare: (viewOrigin: string | undefined) => Effect.Effect; + readonly record: (threadId: ThreadId, tabId: string, reveal: boolean) => Effect.Effect; + readonly foreign: ( + threadId: ThreadId, + tab: { readonly tabId: string; readonly view: string }, + reveal: boolean, + ) => Effect.Effect; + } +>()("t3/lazurio/threadTabs") {} + +/** Overrides of the core's seams, for tests: a fake boot clock, process table or watch. */ +export const ThreadTabsSeams = Context.Reference< + Partial< + Pick< + ThreadTabsCoreSeams, + "epoch" | "bootClock" | "readFile" | "daemon" | "watch" | "everyMs" | "debounceMs" + > + > +>("t3/lazurio/ThreadTabsSeams", { defaultValue: () => ({}) }); + +const asError = (error: unknown) => (isThreadTabsError(error) ? error : refusal("hold-failed")); + +/** No absolute XDG_RUNTIME_DIR: the service is off, writes nothing and says so. */ +const offService = ThreadTabs.of({ + read: () => Effect.succeed({ available: false }), + hold: () => Effect.fail(refusal("unavailable")), + release: () => Effect.fail(refusal("unavailable")), + events: () => + Stream.concat( + Stream.succeed({ type: "snapshot", answer: { available: false } }), + Stream.never, + ), + declare: () => Effect.void, + record: () => Effect.void, + foreign: () => Effect.void, +}); + +const make = Effect.gen(function* () { + const env = yield* HostProcessEnvironment; + const lifecycle = yield* ThreadLifecycle; + const overrides = yield* ThreadTabsSeams; + const runtimeDirectory = env.XDG_RUNTIME_DIR; + if (runtimeDirectory === undefined || !NodePath.isAbsolute(runtimeDirectory)) return offService; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const runFork = Effect.runForkWith(context); + const core = createThreadTabs({ + runtimeDirectory, + socketDirectory: agentBrowserSocketDirectory(env), + epoch: overrides.epoch ?? NodeCrypto.randomBytes(8).toString("hex"), + bootClock: overrides.bootClock ?? (() => readBootClock()), + liveThread: (threadId) => + runPromise(lifecycle.liveThread(ThreadId.make(threadId)).pipe(Effect.map(Option.getOrNull))), + liveThreads: () => runPromise(lifecycle.liveThreads), + readFile: overrides.readFile, + daemon: overrides.daemon, + watch: overrides.watch, + everyMs: overrides.everyMs, + debounceMs: overrides.debounceMs, + log: (event, detail) => { + runFork(Effect.logWarning(event, detail ?? {})); + }, + }); + yield* Effect.addFinalizer(() => Effect.promise(() => core.stop())); + const attempt = (work: () => Promise) => Effect.tryPromise({ try: work, catch: asError }); + return ThreadTabs.of({ + read: (threadId) => attempt(() => core.read(threadId)), + hold: (threadId, tabId) => attempt(() => core.hold(threadId, tabId)), + release: (threadId, tabId) => attempt(() => core.release(threadId, tabId)), + events: (threadId) => + Stream.callback( + (queue) => + Effect.gen(function* () { + // Subscribed first, so no change between the snapshot and the subscription is lost; + // every event is a whole snapshot, so a newer one simply follows. + yield* Effect.acquireRelease( + Effect.sync(() => + core.subscribe(threadId, (event) => { + Queue.offerUnsafe(queue, event); + }), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + const answer = yield* attempt(() => core.read(threadId)); + Queue.offerUnsafe(queue, { type: "snapshot", answer }); + }), + { bufferSize: 64, strategy: "sliding" }, + ), + declare: (viewOrigin) => Effect.sync(() => core.declare(viewOrigin)), + record: (threadId, tabId, reveal) => Effect.promise(() => core.record(threadId, tabId, reveal)), + foreign: (threadId, tab, reveal) => Effect.sync(() => core.foreign(threadId, tab, reveal)), + }); +}); + +export const layer = Layer.effect(ThreadTabs, make); diff --git a/apps/server/src/lazurio/threadTabsRoute.test.ts b/apps/server/src/lazurio/threadTabsRoute.test.ts new file mode 100644 index 000000000000..c080d1f17663 --- /dev/null +++ b/apps/server/src/lazurio/threadTabsRoute.test.ts @@ -0,0 +1,461 @@ +// @effect-diagnostics nodeBuiltinImport:off - the service under the route keeps real files in a temporary runtime directory. +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthSessionId, ThreadId, WS_METHODS, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { httpCompressionLayer } from "../http.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { requiredScopeForRpcMethod } from "../auth/RpcAuthorization.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; +import { ThreadLifecycle } from "./threadLifecycle.ts"; +import * as ThreadTabs from "./threadTabs.ts"; +import * as ThreadTabsRoute from "./threadTabsRoute.ts"; + +const externalOrigin = "https://t3code.vm-01.example.lazurio.io"; +const viewOrigin = "https://browser.vm-01.example.lazurio.io"; +const threadA = ThreadId.make("4a1f9c2e-7b3d-4e5f-8a6b-9c0d1e2f3a4b"); +const archived = ThreadId.make("9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"); +const tab = "0123456789ABCDEF0123456789ABCDEF"; +const route = (path = "") => `${externalOrigin}${ThreadTabsRoute.THREAD_TABS_ROUTE}${path}`; +const requestMetadata = { + deviceType: "desktop" as const, + os: "macOS", + browser: "Chrome", + ipAddress: "192.168.1.23", +}; + +const authLayer = EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + mode: "web" as const, + host: "127.0.0.1", + externalOrigin: new URL(`${externalOrigin}/`), + })), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-thread-tabs-" }))), + ), +); + +/** Only the live thread lives; the other was archived. */ +const lifecycleLayer = Layer.succeed( + ThreadLifecycle, + ThreadLifecycle.of({ + liveThread: (threadId) => + Effect.succeed( + threadId === threadA ? Option.some({ id: threadA, title: "Web" }) : Option.none(), + ), + liveThreads: Effect.succeed([{ id: threadA, title: "Web" }]), + }), +); + +/** The real service on a runtime directory of its own, declared as the host would. */ +const threadTabsLayer = (runtimeDirectory: string | undefined) => + Layer.effectDiscard( + Effect.flatMap(ThreadTabs.ThreadTabs, (tabs) => tabs.declare(viewOrigin)), + ).pipe( + Layer.provideMerge(ThreadTabs.layer), + Layer.provide(lifecycleLayer), + Layer.provide( + Layer.succeed( + HostProcessEnvironment, + runtimeDirectory === undefined ? {} : { XDG_RUNTIME_DIR: runtimeDirectory }, + ), + ), + Layer.provide( + Layer.succeed(ThreadTabs.ThreadTabsSeams, { + epoch: "epoch-1", + bootClock: async () => ({ boot: "6f2c1d0e-1a2b-4c3d-8e4f-5a6b7c8d9e0f", at: 100 }), + watch: () => ({ retry: () => undefined, stop: () => undefined }), + }), + ), + ); + +/** A response that is compressed when nothing opts out, to prove the middleware is on. */ +const controlRoute = HttpRouter.add("GET", "/control", HttpServerResponse.text("x".repeat(8192))); + +const handlerOf = ( + serverAuth: EnvironmentAuth.EnvironmentAuth["Service"], + threadTabs: Layer.Layer, +) => + HttpRouter.toWebHandler( + Layer.mergeAll(ThreadTabsRoute.layer, controlRoute).pipe( + Layer.provide(httpCompressionLayer), + Layer.provideMerge(threadTabs), + Layer.provideMerge(Layer.succeed(EnvironmentAuth.EnvironmentAuth, serverAuth)), + Layer.provideMerge(NodeHttpPlatform.layer), + Layer.provideMerge(NodeServices.layer), + ), + { disableLogger: true }, + ); + +const withRuntime = (use: (runtime: string) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "thread-tabs-route-"))), + use, + (runtime) => Effect.promise(() => NodeFSP.rm(runtime, { recursive: true, force: true })), + ); + +/** A browser session cookie of the real EnvironmentAuth, with these scopes or the standard ones. */ +const sessionCookie = (scopes?: ReadonlyArray) => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const pairing = yield* serverAuth.issuePairingCredential( + scopes === undefined ? undefined : { scopes: [...scopes] }, + ); + const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata); + return `${sessions.cookieName}=${exchanged.sessionToken}`; + }); + +const post = (path: string, body: unknown, headers: Record) => + new Request(route(path), { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + +/** Reads server-sent events off a response body, one at a time; comments are skipped. */ +const eventReader = (response: Response) => { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + return { + next: async (): Promise<{ readonly event: string; readonly data: unknown }> => { + for (;;) { + const end = buffer.indexOf("\n\n"); + if (end >= 0) { + const frame = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + if (frame.startsWith(":")) continue; + const event = /^event: (.*)$/m.exec(frame)?.[1] ?? "message"; + const data = /^data: (.*)$/m.exec(frame)?.[1] ?? "null"; + return { event, data: JSON.parse(data) }; + } + const { value, done } = await reader.read(); + if (done) throw new Error("the stream ended"); + buffer += decoder.decode(value, { stream: true }); + } + }, + cancel: () => reader.cancel(), + }; +}; + +// The real clock: the handler's own runtime verifies the sessions minted here by the real one. +it.layer(authLayer.pipe(Layer.provide(NodeServices.layer)), { excludeTestServices: true })( + "the thread tabs route", + (it) => { + it.effect( + "answers the snapshot, 404 for an unknown or archived thread, and unavailable while off", + () => + withRuntime((runtime) => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const cookie = yield* sessionCookie(); + const on = handlerOf(serverAuth, threadTabsLayer(runtime)); + const off = handlerOf(serverAuth, threadTabsLayer(undefined)); + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.all([on.dispose(), off.dispose()])), + ); + yield* Effect.tryPromise(async () => { + const empty = await on.handler( + new Request(route(`?threadId=${threadA}`), { headers: { cookie } }), + ); + expect(empty.status).toBe(200); + expect(await empty.json()).toEqual({ + available: true, + epoch: "epoch-1", + revision: expect.any(Number), + threadId: threadA, + strip: `t3-${threadA}`, + viewOrigin, + tabs: [], + }); + const held = await on.handler( + post( + "/hold", + { threadId: threadA, tabId: tab.toLowerCase() }, + { cookie, origin: externalOrigin }, + ), + ); + expect(held.status).toBe(200); + expect(((await held.json()) as ThreadTabs.ThreadTabsSnapshot).tabs).toEqual([ + { tabId: tab, view: `${viewOrigin}/t/${tab}`, agent: false, alsoHeldBy: [] }, + ]); + for (const threadId of [archived, "5c4b3a29-1807-4f6e-9d5c-4b3a2918f7e6"]) { + const missing = await on.handler( + new Request(route(`?threadId=${threadId}`), { headers: { cookie } }), + ); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: "thread-not-found" }); + } + const released = await on.handler( + post( + "/release", + { threadId: threadA, tabId: tab }, + { cookie, origin: externalOrigin }, + ), + ); + expect(((await released.json()) as ThreadTabs.ThreadTabsSnapshot).tabs).toEqual([]); + // No runtime directory: the service is off and says so. + const unavailable = await off.handler( + new Request(route(`?threadId=${threadA}`), { headers: { cookie } }), + ); + expect(await unavailable.json()).toEqual({ available: false }); + const refused = await off.handler( + post( + "/hold", + { threadId: threadA, tabId: tab }, + { cookie, origin: externalOrigin }, + ), + ); + expect(refused.status).toBe(503); + expect(await refused.json()).toEqual({ error: "unavailable" }); + }); + }), + ).pipe(Effect.scoped), + ); + + it.effect("authenticates as T3 Code's routes do, with upstream's preview scopes", () => + withRuntime((runtime) => + Effect.gen(function* () { + expect(ThreadTabsRoute.threadTabsScopes).toEqual({ + read: requiredScopeForRpcMethod(WS_METHODS.previewList), + hold: requiredScopeForRpcMethod(WS_METHODS.previewOpen), + release: requiredScopeForRpcMethod(WS_METHODS.previewClose), + }); + expect(ThreadTabsRoute.threadTabsScopes).toEqual({ + read: "orchestration:read", + hold: "orchestration:operate", + release: "orchestration:operate", + }); + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const cookie = yield* sessionCookie(); + const readOnly = yield* sessionCookie(["orchestration:read"]); + const { handler, dispose } = handlerOf(serverAuth, threadTabsLayer(runtime)); + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())); + yield* Effect.tryPromise(async () => { + // A same-origin GET and EventSource send no Origin: they pass. + const read = await handler( + new Request(route(`?threadId=${threadA}`), { headers: { cookie } }), + ); + expect(read.status).toBe(200); + const events = await handler( + new Request(route(`/events?threadId=${threadA}`), { headers: { cookie } }), + ); + expect(events.status).toBe(200); + expect(events.headers.get("content-type")).toContain("text/event-stream"); + await events.body?.cancel(); + // A cookie-authenticated POST must come from the external origin. + const body = { threadId: threadA, tabId: tab }; + for (const origin of [undefined, "https://sibling.vm-01.example.lazurio.io"]) { + const response = await handler( + post("/hold", body, origin === undefined ? { cookie } : { cookie, origin }), + ); + expect(response.status).toBe(401); + } + expect( + (await handler(post("/hold", body, { cookie, origin: externalOrigin }))).status, + ).toBe(200); + // No credential at all. + expect((await handler(new Request(route(`?threadId=${threadA}`)))).status).toBe(401); + // Reading is not holding. + expect( + ( + await handler( + new Request(route(`?threadId=${threadA}`), { headers: { cookie: readOnly } }), + ) + ).status, + ).toBe(200); + expect( + (await handler(post("/release", body, { cookie: readOnly, origin: externalOrigin }))) + .status, + ).toBe(403); + }); + }), + ).pipe(Effect.scoped), + ); + + it.effect("streams the snapshot at once and on every change, never compressed", () => + withRuntime((runtime) => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const cookie = yield* sessionCookie(); + const { handler, dispose } = handlerOf(serverAuth, threadTabsLayer(runtime)); + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())); + yield* Effect.tryPromise(async () => { + const control = await handler( + new Request(`${externalOrigin}/control`, { headers: { "accept-encoding": "gzip" } }), + ); + expect(control.headers.get("content-encoding")).toBe("gzip"); + await control.body?.cancel(); + const response = await handler( + new Request(route(`/events?threadId=${threadA}`), { + headers: { cookie, "accept-encoding": "gzip, br" }, + }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("cache-control")).toBe("no-cache, no-transform"); + expect(response.headers.get("x-accel-buffering")).toBe("no"); + const events = eventReader(response); + expect(await events.next()).toMatchObject({ + event: "snapshot", + data: { available: true, threadId: threadA, tabs: [] }, + }); + await handler( + post("/hold", { threadId: threadA, tabId: tab }, { cookie, origin: externalOrigin }), + ); + expect(await events.next()).toMatchObject({ + event: "snapshot", + data: { tabs: [{ tabId: tab, view: `${viewOrigin}/t/${tab}` }] }, + }); + await events.cancel(); + // An unknown thread is a 404, on which an EventSource stops. + const unknown = await handler( + new Request(route(`/events?threadId=${archived}`), { headers: { cookie } }), + ); + expect(unknown.status).toBe(404); + }); + }), + ).pipe(Effect.scoped), + ); + }, +); + +/** A service that answers what each test needs, to read the route's own mapping. */ +const fakeService = (overrides: Partial) => + Layer.succeed( + ThreadTabs.ThreadTabs, + ThreadTabs.ThreadTabs.of({ + read: () => + Effect.succeed({ + available: true, + epoch: "e", + revision: 1, + threadId: threadA, + strip: `t3-${threadA}`, + viewOrigin, + tabs: [], + }), + hold: () => Effect.die("unused"), + release: () => Effect.die("unused"), + events: () => Stream.never, + declare: () => Effect.void, + record: () => Effect.void, + foreign: () => Effect.void, + ...overrides, + }), + ); + +const bearerAuth = (scopes: ReadonlyArray) => + ({ + authenticateHttpRequest: () => + Effect.succeed({ + sessionId: AuthSessionId.make("test"), + subject: "test", + method: "bearer-access-token", + scopes, + }), + }) as unknown as EnvironmentAuth.EnvironmentAuth["Service"]; + +it.effect("validates bodies and maps each refusal to its status", () => + Effect.gen(function* () { + const reasons: Array = []; + const { handler, dispose } = handlerOf( + bearerAuth(["orchestration:read", "orchestration:operate"]), + fakeService({ + hold: () => Effect.fail(new ThreadTabs.ThreadTabsError({ reason: reasons.shift()! })), + }), + ); + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())); + yield* Effect.tryPromise(async () => { + const headers = { authorization: "Bearer token" }; + for (const body of [ + { threadId: threadA, tabId: "nope" }, + { threadId: threadA }, + { tabId: tab }, + { threadId: "", tabId: tab }, + "not json", + ]) { + const response = await handler(post("/hold", body, headers)); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid-request" }); + } + expect((await handler(new Request(route(), { headers }))).status).toBe(400); + for (const [reason, status] of [ + ["too-many-tabs", 409], + ["thread-not-found", 404], + ["holder-unreadable", 503], + ["hold-failed", 503], + ["unavailable", 503], + ] as const) { + reasons.push(reason); + const response = await handler(post("/hold", { threadId: threadA, tabId: tab }, headers)); + expect([response.status, await response.json()]).toEqual([status, { error: reason }]); + } + }); + }).pipe(Effect.scoped), +); + +it.effect( + "sends another Environment's tab as a foreign event and ends the stream with the client", + () => + Effect.gen(function* () { + let ended = () => {}; + const streamEnded = new Promise((resolve) => { + ended = resolve; + }); + const foreignView = `https://browser.vm-02.acme.lazurio.io/t/${tab}`; + const { handler, dispose } = handlerOf( + bearerAuth(["orchestration:read"]), + fakeService({ + events: () => + Stream.concat( + Stream.fromIterable([ + { type: "snapshot", answer: { available: false } }, + { + type: "foreign", + tab: { tabId: tab, view: foreignView, revealRequest: { id: "e:1" } }, + }, + ]), + Stream.never, + ).pipe(Stream.ensuring(Effect.sync(() => ended()))), + }), + ); + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())); + yield* Effect.tryPromise(async () => { + const response = await handler( + new Request(route(`/events?threadId=${threadA}`), { + headers: { authorization: "Bearer token" }, + }), + ); + const events = eventReader(response); + expect(await events.next()).toEqual({ event: "snapshot", data: { available: false } }); + expect(await events.next()).toEqual({ + event: "foreign", + data: { tabId: tab, view: foreignView, revealRequest: { id: "e:1" } }, + }); + await events.cancel(); + await streamEnded; + }); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/lazurio/threadTabsRoute.ts b/apps/server/src/lazurio/threadTabsRoute.ts new file mode 100644 index 000000000000..82ffc14a12fa --- /dev/null +++ b/apps/server/src/lazurio/threadTabsRoute.ts @@ -0,0 +1,168 @@ +/** + * Lazurio overlay (plan DEV-6646, task 501): the web's way to a thread's tabs (threadTabs.ts), + * under a prefix upstream does not use, so `packages/contracts` stays upstream's. + * + * - `GET /api/lazurio/thread-tabs?threadId=`: the snapshot, or `{"available": false}`. + * - `GET /api/lazurio/thread-tabs/events?threadId=`: `text/event-stream` of `snapshot` + * events (the same JSON) at once and on every change, `foreign` events, and a comment every + * 25 s. Every event is a whole snapshot, so a reconnect replays nothing. + * - `POST /api/lazurio/thread-tabs/hold` and `/release`, `{threadId, tabId}`: the snapshot after. + * + * Errors are `{"error": }`: 400 invalid-request, 404 thread-not-found, 409 too-many-tabs, + * 503 unavailable | holder-unreadable | hold-failed. Requests authenticate as any of T3 Code's + * HTTP routes do (a session cookie, or a bearer or DPoP token); a cookie-authenticated POST must + * come from the external origin (EnvironmentAuth), while a same-origin EventSource, which sends no + * Origin, passes. The scopes are upstream's for listing, opening and closing a preview + * (`requiredScopeForRpcMethod`), so they follow upstream. The stream is never compressed or + * buffered on the way: `cache-control: no-transform` keeps the compression middleware off it. + */ + +import { ThreadId, WS_METHODS, type AuthEnvironmentScope } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { + failEnvironmentAuthInvalid, + failEnvironmentInternal, + failEnvironmentScopeRequired, +} from "../auth/http.ts"; +import { requiredScopeForRpcMethod } from "../auth/RpcAuthorization.ts"; +import * as ThreadTabs from "./threadTabs.ts"; + +export const THREAD_TABS_ROUTE = "/api/lazurio/thread-tabs"; +const HEARTBEAT = Duration.seconds(25); + +const STATUS: Record = { + "invalid-request": 400, + "thread-not-found": 404, + "too-many-tabs": 409, + unavailable: 503, + "holder-unreadable": 503, + "hold-failed": 503, +}; + +/** The scopes of the preview RPCs the route stands beside, read from upstream's own table. */ +export const threadTabsScopes = { + read: requiredScopeForRpcMethod(WS_METHODS.previewList), + hold: requiredScopeForRpcMethod(WS_METHODS.previewOpen), + release: requiredScopeForRpcMethod(WS_METHODS.previewClose), +}; + +const decodeThreadId = Schema.decodeUnknownOption(ThreadId); +const TabRequest = Schema.Struct({ + threadId: ThreadId, + tabId: Schema.String.check(Schema.isPattern(/^[0-9A-Fa-f]{32}$/)), +}); + +const refused = (reason: ThreadTabs.ThreadTabsErrorReason) => + HttpServerResponse.jsonUnsafe({ error: reason }, { status: STATUS[reason] }); +const answered = (effect: Effect.Effect) => + effect.pipe( + Effect.map((body) => HttpServerResponse.jsonUnsafe(body)), + Effect.catchTag("ThreadTabsError", (error) => Effect.succeed(refused(error.reason))), + ); + +/** One server-sent event: the thread's snapshot, or another Environment's tab. */ +const serverSentEvent = (event: ThreadTabs.ThreadTabsEvent) => + event.type === "snapshot" + ? `event: snapshot\ndata: ${JSON.stringify(event.answer)}\n\n` + : `event: foreign\ndata: ${JSON.stringify(event.tab)}\n\n`; + +export const layer = HttpRouter.use((router) => + Effect.gen(function* () { + const threadTabs = yield* ThreadTabs.ThreadTabs; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + + const authenticate = (scope: AuthEnvironmentScope) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const session = yield* serverAuth.authenticateHttpRequest(request).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (EnvironmentAuth.isServerAuthCredentialError(error)) { + return yield* failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ); + } + return yield* failEnvironmentInternal("internal_error", error); + }), + ), + ); + if (!session.scopes.includes(scope)) return yield* failEnvironmentScopeRequired(scope); + }); + + const threadIdOfQuery = Effect.map(HttpServerRequest.HttpServerRequest, (request) => + Option.flatMap(HttpServerRequest.toURL(request), (url) => + decodeThreadId(url.searchParams.get("threadId")), + ), + ); + + const tabRequest = HttpServerRequest.schemaBodyJson(TabRequest).pipe( + Effect.asSome, + Effect.orElseSucceed(() => Option.none()), + ); + + yield* router.add( + "GET", + THREAD_TABS_ROUTE, + Effect.gen(function* () { + yield* authenticate(threadTabsScopes.read); + const threadId = yield* threadIdOfQuery; + if (Option.isNone(threadId)) return refused("invalid-request"); + return yield* answered(threadTabs.read(threadId.value)); + }), + ); + + yield* router.add( + "GET", + `${THREAD_TABS_ROUTE}/events`, + Effect.gen(function* () { + yield* authenticate(threadTabsScopes.read); + const threadId = yield* threadIdOfQuery; + if (Option.isNone(threadId)) return refused("invalid-request"); + // An unknown or retired thread is a 404, on which an EventSource stops reconnecting. + const first = yield* threadTabs.read(threadId.value).pipe(Effect.result); + if (first._tag === "Failure") return refused(first.failure.reason); + const events = threadTabs + .events(threadId.value) + .pipe(Stream.ignore, Stream.map(serverSentEvent)); + const heartbeat = Stream.tick(HEARTBEAT).pipe( + Stream.drop(1), + Stream.map(() => ": keep-alive\n\n"), + ); + return HttpServerResponse.stream(Stream.merge(events, heartbeat).pipe(Stream.encodeText), { + contentType: "text/event-stream", + headers: { "cache-control": "no-cache, no-transform", "x-accel-buffering": "no" }, + }); + }), + ); + + yield* router.add( + "POST", + `${THREAD_TABS_ROUTE}/hold`, + Effect.gen(function* () { + yield* authenticate(threadTabsScopes.hold); + const body = yield* tabRequest; + if (Option.isNone(body)) return refused("invalid-request"); + return yield* answered(threadTabs.hold(body.value.threadId, body.value.tabId)); + }), + ); + + yield* router.add( + "POST", + `${THREAD_TABS_ROUTE}/release`, + Effect.gen(function* () { + yield* authenticate(threadTabsScopes.release); + const body = yield* tabRequest; + if (Option.isNone(body)) return refused("invalid-request"); + return yield* answered(threadTabs.release(body.value.threadId, body.value.tabId)); + }), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5e6c926e1e32..963e4aec5dcf 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -76,7 +76,7 @@ import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; -import * as LazurioEnvironmentBrowser from "./lazurio/environmentBrowser.ts"; +import * as LazurioOverlay from "./lazurio/overlay.ts"; import { deviceHubProxyRouteLayer } from "./device/DeviceHubProxy.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -613,9 +613,9 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), - // Lazurio overlay (plan DEV-6646): T3's browser tools drive the Environment browser. It shares - // the routes' one preview broker, provided below. - LazurioEnvironmentBrowser.layer.pipe(Layer.provide(ProcessRunner.layer)), + // Lazurio overlay (plan DEV-6646): T3's browser tools drive the Environment browser, and each + // thread keeps its tabs of it. It shares the routes' one preview broker, provided below. + LazurioOverlay.layer.pipe(Layer.provide(ProcessRunner.layer)), // Last, so no route layer can replace the server's one TracerDisabledWhen. untracedRequestsLayer, ).pipe( From 3435d7ceed827a83cabc998c5803da06d9befbd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 04:35:46 +0200 Subject: [PATCH 3/8] feat(server): archiving or deleting a thread lets go of its tabs and stops its agent's daemon When a thread is archived or deleted, its holder file goes, so the hub ends every tab nothing else keeps, and the thread's agent-browser daemon is stopped: SIGTERM to the process its .pid names, only when that is a live process whose command line is agent-browser's, so a reused pid is never signalled. Tabs another thread or the Launchpad holds stay. At the start, the same happens for threads that are no longer live, including those archived while T3 Code was down, after command readiness so the projection has caught up; names outside t3- are never touched. An unarchived thread holds tabs again, and a renamed thread's file takes its new title as its label. threadLifecycle.ts is the one adapter to T3 Code's orchestration: it reads v0.0.45's domain events and the shape of upstream's orchestration v2, so the 0.0.46 refresh swaps only the stream. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/lazurio/overlay.ts | 2 +- .../src/lazurio/threadLifecycle.test.ts | 61 ++++++ apps/server/src/lazurio/threadLifecycle.ts | 64 +++++- apps/server/src/lazurio/threadTabs.test.ts | 193 +++++++++++++++++- apps/server/src/lazurio/threadTabs.ts | 154 ++++++++++++-- .../src/lazurio/threadTabsRoute.test.ts | 2 + 6 files changed, 452 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/lazurio/threadLifecycle.test.ts diff --git a/apps/server/src/lazurio/overlay.ts b/apps/server/src/lazurio/overlay.ts index 5f5d7bfabce6..185232d156ba 100644 --- a/apps/server/src/lazurio/overlay.ts +++ b/apps/server/src/lazurio/overlay.ts @@ -3,7 +3,7 @@ * line of `server.ts` joins into upstream's routes. The Environment browser host drives the * Environment browser for T3's browser tools (environmentBrowser.ts); each thread keeps its tabs * of that browser in its holder file (threadTabs.ts), which the web reaches through - * threadTabsRoute.ts, and which reads the threads through threadLifecycle.ts. + * threadTabsRoute.ts, and archiving or deleting a thread lets go of them (threadLifecycle.ts). */ import * as Layer from "effect/Layer"; diff --git a/apps/server/src/lazurio/threadLifecycle.test.ts b/apps/server/src/lazurio/threadLifecycle.test.ts new file mode 100644 index 000000000000..8eed7eefb27e --- /dev/null +++ b/apps/server/src/lazurio/threadLifecycle.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { threadLifecycleEvent } from "./threadLifecycle.ts"; + +const threadId = "4a1f9c2e-7b3d-4e5f-8a6b-9c0d1e2f3a4b"; + +/** v0.0.45's orchestration events (packages/contracts/src/orchestration.ts): the thread in the payload. */ +const v045 = { + created: { + type: "thread.created", + payload: { threadId, projectId: "p", title: "Web", createdAt: "x", updatedAt: "x" }, + }, + renamed: { type: "thread.meta-updated", payload: { threadId, title: "Web shop" } }, + reordered: { type: "thread.meta-updated", payload: { threadId, activeOrderKey: "a0" } }, + archived: { type: "thread.archived", payload: { threadId, archivedAt: "x", updatedAt: "x" } }, + unarchived: { type: "thread.unarchived", payload: { threadId, updatedAt: "x" } }, + deleted: { type: "thread.deleted", payload: { threadId, deletedAt: "x" } }, +}; + +/** Upstream main's orchestration v2 events (orchestrationV2.ts): the thread on the event, the + * whole thread as the payload. */ +const appThread = (title: string) => ({ id: threadId, projectId: "p", title, branch: null }); +const v2 = { + created: { type: "thread.created", threadId, payload: appThread("Web") }, + renamed: { type: "thread.metadata-updated", threadId, payload: appThread("Web shop") }, + archived: { type: "thread.archived", threadId, payload: appThread("Web shop") }, + unarchived: { type: "thread.unarchived", threadId, payload: appThread("Web shop") }, + deleted: { type: "thread.deleted", threadId, payload: appThread("Web shop") }, +}; + +describe("the threads' lifecycle", () => { + it("reads the same retired, restored and titled from both orchestrations", () => { + for (const events of [v045, v2]) { + expect(threadLifecycleEvent(events.created)).toEqual({ + type: "titled", + threadId, + title: "Web", + }); + expect(threadLifecycleEvent(events.renamed)).toEqual({ + type: "titled", + threadId, + title: "Web shop", + }); + expect(threadLifecycleEvent(events.archived)).toEqual({ type: "retired", threadId }); + expect(threadLifecycleEvent(events.deleted)).toEqual({ type: "retired", threadId }); + expect(threadLifecycleEvent(events.unarchived)).toEqual({ type: "restored", threadId }); + } + }); + + it("ignores every other event, and one without a thread or a title", () => { + expect(threadLifecycleEvent(v045.reordered)).toBeNull(); + expect( + threadLifecycleEvent({ type: "thread.turn-start-requested", payload: { threadId } }), + ).toBeNull(); + expect(threadLifecycleEvent({ type: "run.created", threadId, payload: {} })).toBeNull(); + expect(threadLifecycleEvent({ type: "thread.archived", payload: {} })).toBeNull(); + expect( + threadLifecycleEvent({ type: "thread.created", payload: { threadId, title: " " } }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/lazurio/threadLifecycle.ts b/apps/server/src/lazurio/threadLifecycle.ts index 48f582249a87..1e56776a9706 100644 --- a/apps/server/src/lazurio/threadLifecycle.ts +++ b/apps/server/src/lazurio/threadLifecycle.ts @@ -1,7 +1,16 @@ /** * Lazurio overlay (plan DEV-6646, task 501): what the threads' tabs need of T3 Code's * orchestration, behind one adapter, so a port to another orchestration rewrites only this file. - * The live threads are those the projection shows: neither archived nor deleted. + * A thread that is archived or deleted is `retired` and lets go of its tabs; one that is + * unarchived is `restored` (its tabs ended and do not come back); a title is the label of the + * thread's holder file. The live threads are those the projection shows: neither archived nor + * deleted. + * + * v0.0.45 reads `OrchestrationEngineService.streamDomainEvents`, whose events carry the thread in + * `payload.threadId` and a new title on `thread.created` / `thread.meta-updated`. Upstream's + * orchestration v2 (`OrchestratorV2.streamDomainEvents`) carries it in `event.threadId`, and the + * title on `thread.created` / `thread.metadata-updated`. `threadLifecycleEvent` reads both shapes + * (threadLifecycle.test.ts), so the port swaps only the stream and the reads. */ import type { ThreadId } from "@t3tools/contracts"; @@ -10,8 +19,16 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; + +export type ThreadLifecycleEvent = + | { readonly type: "retired"; readonly threadId: string } + | { readonly type: "restored"; readonly threadId: string } + | { readonly type: "titled"; readonly threadId: string; readonly title: string }; /** A live thread: its id and title. */ export interface LiveThread { @@ -19,6 +36,40 @@ export interface LiveThread { readonly title: string; } +const nonEmpty = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +/** The lifecycle meaning of an orchestration event of either shape, or null for any other. */ +export function threadLifecycleEvent(event: { + readonly type: string; + readonly threadId?: unknown; + readonly payload?: unknown; +}): ThreadLifecycleEvent | null { + const payload = + typeof event.payload === "object" && event.payload !== null + ? (event.payload as Record) + : {}; + const threadId = nonEmpty(event.threadId) + ? event.threadId + : nonEmpty(payload.threadId) + ? payload.threadId + : null; + if (threadId === null) return null; + switch (event.type) { + case "thread.archived": + case "thread.deleted": + return { type: "retired", threadId }; + case "thread.unarchived": + return { type: "restored", threadId }; + case "thread.created": + case "thread.meta-updated": + case "thread.metadata-updated": + return nonEmpty(payload.title) ? { type: "titled", threadId, title: payload.title } : null; + default: + return null; + } +} + /** The projection could not be read. */ export class ThreadLifecycleReadError extends Schema.TaggedError()( "ThreadLifecycleReadError", @@ -32,6 +83,10 @@ export class ThreadLifecycleReadError extends Schema.TaggedError; + /** The lifecycle of every thread from now on (a hot stream, no replay). */ + readonly events: Stream.Stream; /** The live thread with this id, or none for an unknown, archived or deleted one. */ readonly liveThread: ( threadId: ThreadId, @@ -42,8 +97,15 @@ export class ThreadLifecycle extends Context.Service< >()("t3/lazurio/threadLifecycle") {} const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; const projection = yield* ProjectionSnapshotQuery; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; return ThreadLifecycle.of({ + ready: startup.awaitCommandReady.pipe(Effect.ignore), + events: engine.streamDomainEvents.pipe( + Stream.map(threadLifecycleEvent), + Stream.filter((event): event is ThreadLifecycleEvent => event !== null), + ), liveThread: (threadId) => projection.getThreadShellById(threadId).pipe( Effect.map(Option.map((thread) => ({ id: thread.id, title: thread.title }))), diff --git a/apps/server/src/lazurio/threadTabs.test.ts b/apps/server/src/lazurio/threadTabs.test.ts index ff0e28d26415..4e4e27a13106 100644 --- a/apps/server/src/lazurio/threadTabs.test.ts +++ b/apps/server/src/lazurio/threadTabs.test.ts @@ -40,7 +40,7 @@ afterEach(async () => { for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); }); -async function world(options: { readonly declared?: boolean } = {}) { +async function world(options: { readonly declared?: boolean; readonly ready?: boolean } = {}) { const runtime = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "thread-tabs-")); cleanups.push(() => NodeFSP.rm(runtime, { recursive: true, force: true })); const holders = holderDirectory({ XDG_RUNTIME_DIR: runtime })!; @@ -54,10 +54,20 @@ async function world(options: { readonly declared?: boolean } = {}) { const listReads: number[] = []; /** The fake process table: pid → command line. */ const processes = new Map(); + const terminated: number[] = []; const logs: string[] = []; const watched: Array> = []; let retries = 0; let changed: () => void = () => undefined; + /** Reads of a live thread wait here while the test holds them back (`holdLiveReads`). */ + let liveGate: { readonly open: Promise; readonly reached: () => void } | null = null; + let markReady = () => {}; + const ready = + options.ready === false + ? new Promise((resolve) => { + markReady = resolve; + }) + : Promise.resolve(); const make = () => createThreadTabs({ runtimeDirectory: runtime, @@ -65,16 +75,29 @@ async function world(options: { readonly declared?: boolean } = {}) { epoch: "epoch-1", bootClock: async () => clock, liveThread: async (threadId) => { + const gate = liveGate; + if (gate !== null) { + gate.reached(); + await gate.open; + } const title = threads.get(threadId); return title === undefined ? null : { id: threadId as never, title }; }, liveThreads: async () => [...threads].map(([threadId, title]) => ({ id: threadId as never, title })), + ready, readFile: async (path) => { if (path === listPath) listReads.push(listReads.length); return NodeFSP.readFile(path, "utf8"); }, - daemon: { alive: (pid) => processes.has(pid) }, + daemon: { + alive: (pid) => processes.has(pid), + commandLine: async (pid) => processes.get(pid) ?? "", + terminate: (pid) => { + terminated.push(pid); + processes.delete(pid); + }, + }, watch: (directories, onChange) => { watched.push(directories); changed = onChange; @@ -98,7 +121,7 @@ async function world(options: { readonly declared?: boolean } = {}) { core.declare(viewOrigin); await core.settled(); }; - if (options.declared !== false) await declare(); + if (options.declared !== false && options.ready !== false) await declare(); const fileOf = (strip: string) => NodePath.join(holders, `${strip}.json`); return { runtime, @@ -109,10 +132,32 @@ async function world(options: { readonly declared?: boolean } = {}) { events, threads, processes, + terminated, logs, watched, listReads, declare, + markReady, + /** Holds the next reads of a live thread back: `reading` settles once one waits, `release` + * lets them through. */ + holdLiveReads: () => { + let release = () => {}; + let reached = () => {}; + const reading = new Promise((resolve) => { + reached = resolve; + }); + const open = new Promise((resolve) => { + release = resolve; + }); + liveGate = { open, reached }; + return { + reading, + release: () => { + liveGate = null; + release(); + }, + }; + }, retries: () => retries, changed: () => changed(), fileOf, @@ -421,11 +466,16 @@ describe("holds and releases", () => { expect((JSON.parse(text) as { label: string }).label).toHaveLength(200); }); - it("an unknown thread takes no hold", async () => { + it("an unknown or retired thread takes no hold", async () => { const w = await world(); await expect(w.core.hold("5c4b3a29-1807-4f6e-9d5c-4b3a2918f7e6", id(1))).rejects.toMatchObject({ reason: "thread-not-found", }); + await w.core.lifecycle({ type: "retired", threadId: threadA }); + await expect(w.core.hold(threadA, id(1))).rejects.toMatchObject({ reason: "thread-not-found" }); + await expect(w.core.read(threadA)).rejects.toMatchObject({ reason: "thread-not-found" }); + await w.core.record(threadA, id(2), true); + expect(await w.stored()).toBeNull(); }); it("a release of the last id removes the file; a tab not held is released already", async () => { @@ -439,6 +489,135 @@ describe("holds and releases", () => { reason: "invalid-request", }); }); + + it("a title change rewrites only an existing file's label", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + await w.core.lifecycle({ type: "titled", threadId: threadA, title: "Web shop" }); + expect(JSON.parse(await NodeFSP.readFile(w.fileOf(stripA), "utf8")).label).toBe("Web shop"); + await w.core.lifecycle({ type: "titled", threadId: threadB, title: "Docs v2" }); + expect(await w.stored(stripB)).toBeNull(); + }); +}); + +describe("archive and delete", () => { + it("removes the thread's file and stops only its live agent-browser daemon, never a reused pid", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + await w.core.hold(threadB, id(2)); + await w.bind(stripA, id(1), 4242); + await w.bind(stripB, id(2), 4343); + w.processes.set(4242, "/home/u/.local/bin/agent-browser\0"); + w.processes.set(4343, "/usr/bin/vim\0notes.txt\0"); + await w.core.lifecycle({ type: "retired", threadId: threadA }); + expect(await w.stored()).toBeNull(); + expect(w.terminated).toEqual([4242]); + expect(tabIds(w.lastSnapshot())).toEqual([]); + // The pid in B's .pid is another program now: nothing is killed. + await w.core.lifecycle({ type: "retired", threadId: threadB }); + expect(w.terminated).toEqual([4242]); + expect(w.logs).toContain("thread-tabs-daemon-not-agent-browser"); + }); + + it("closes no tab itself: a tab another file holds stays held there", async () => { + const w = await world(); + await w.storeFile("launchpad", "Launchpad", [{ id: id(1), recordedAt: at(90) }]); + await w.core.hold(threadA, id(1)); + await w.core.lifecycle({ type: "retired", threadId: threadA }); + expect(await w.stored("launchpad")).toEqual([{ id: id(1), recordedAt: at(90) }]); + }); + + it("an unarchive brings nothing back but lets the thread hold again", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + await w.core.lifecycle({ type: "retired", threadId: threadA }); + await w.core.lifecycle({ type: "restored", threadId: threadA }); + expect(tabIds(await w.core.read(threadA))).toEqual([]); + await w.core.hold(threadA, id(2)); + expect(tabIds(await w.core.read(threadA))).toEqual([id(2)]); + }); + + it("removes even a file it cannot read", async () => { + const w = await world(); + await NodeFSP.mkdir(w.holders, { recursive: true }); + await NodeFSP.writeFile(w.fileOf(stripA), "{"); + await w.core.lifecycle({ type: "retired", threadId: threadA }); + expect(await w.stored()).toBeNull(); + }); + + it("wins over a person's hold whose thread read was still under way: nothing is written back", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + const read = w.holdLiveReads(); + const holding = w.core.hold(threadA, id(2)); + await read.reading; + // The projection still shows the thread; only the archive's mark stops the write. + const retiring = w.core.lifecycle({ type: "retired", threadId: threadA }); + read.release(); + await expect(holding).rejects.toMatchObject({ reason: "thread-not-found" }); + await retiring; + await w.core.settled(); + expect(await w.stored()).toBeNull(); + }); + + it("wins over an agent's tab whose thread read was still under way, without a warning", async () => { + const w = await world(); + await w.core.hold(threadA, id(1)); + const read = w.holdLiveReads(); + const recording = w.core.record(threadA, id(3), true); + await read.reading; + const retiring = w.core.lifecycle({ type: "retired", threadId: threadA }); + read.release(); + await recording; + await retiring; + await w.core.settled(); + expect(await w.stored()).toBeNull(); + expect(w.logs).not.toContain("thread-tabs-record-failed"); + }); +}); + +describe("the startup sweep", () => { + it("removes files and stops daemons of no live thread; names outside t3- are never touched", async () => { + const w = await world({ declared: false }); + const gone = "5c4b3a29-1807-4f6e-9d5c-4b3a2918f7e6"; + const stripGone = agentBrowserSessionName(gone); + const stripDaemon = agentBrowserSessionName("7d6c5b4a-3928-4170-8e6f-5d4c3b2a1908"); + await w.storeFile(stripGone, "Archived", [{ id: id(1), recordedAt: at(50) }]); + await w.bind(stripGone, id(1), 5001); + await w.bind(stripDaemon, id(2), 5002); + await w.bind("codex-1", id(3), 5003); + await w.storeFile("launchpad", "Launchpad", [{ id: id(4), recordedAt: at(50) }]); + await w.storeFile(stripA, "Web", [ + { id: id(5), recordedAt: at(50) }, + { id: id(6), recordedAt: at(50) }, + ]); + for (const pid of [5001, 5002, 5003]) w.processes.set(pid, "agent-browser\0"); + await w.list([id(4), id(6)], at(100)); + await w.declare(); + expect(await w.stored(stripGone)).toBeNull(); + expect(w.terminated.sort()).toEqual([5001, 5002]); + expect(await w.stored("launchpad")).toEqual([{ id: id(4), recordedAt: at(50) }]); + // Live threads' files are kept and reconciled. + expect(await w.stored()).toEqual([{ id: id(6), recordedAt: at(50) }]); + }); + + it("waits for the projection to catch up before it reads which threads live", async () => { + const w = await world({ ready: false }); + await w.storeFile(stripA, "Web", [{ id: id(1), recordedAt: at(50) }]); + w.core.declare(viewOrigin); + w.threads.clear(); + let read = false; + const reading = w.core.read(threadA).then( + () => (read = true), + () => (read = true), + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(read).toBe(false); + w.threads.set(threadA, "Web"); + w.markReady(); + await reading; + expect(await w.stored()).toEqual([{ id: id(1), recordedAt: at(50) }]); + }); }); describe("tabs an agent opens", () => { @@ -470,13 +649,17 @@ describe("tabs an agent opens", () => { expect(snapshot.tabs[0]?.revealRequest).toBeDefined(); }); - it("a binding unchanged after a release is not recorded again", async () => { + it("a binding unchanged after a release is not recorded again; a retired thread's is ignored", async () => { const w = await world(); await w.bind(stripA, id(3)); await w.core.refresh(); await w.core.release(threadA, id(3)); await w.core.refresh(); expect(await w.stored()).toBeNull(); + await w.core.lifecycle({ type: "retired", threadId: threadB }); + await w.bind(stripB, id(4)); + await w.core.refresh(); + expect(await w.stored(stripB)).toBeNull(); }); it("another Environment's tab goes to the thread's subscribers only, live", async () => { diff --git a/apps/server/src/lazurio/threadTabs.ts b/apps/server/src/lazurio/threadTabs.ts index 76aa8155fb72..8f22ac1b980b 100644 --- a/apps/server/src/lazurio/threadTabs.ts +++ b/apps/server/src/lazurio/threadTabs.ts @@ -21,8 +21,10 @@ * A tab enters a thread's strip when a person holds it (the web, threadTabsRoute.ts), when * preview_open opens or adopts it (environmentBrowser.ts `adopt`), and when the thread's own * agent-browser session binds another tab (`t3-.target`, watched as the hub watches it). - * The service is off, answering `{available: false}`, without an absolute XDG_RUNTIME_DIR or - * while the Environment does not declare its browser (the Environment browser host's declaration). + * Archiving or deleting a thread removes its file and stops its agent's daemon; at the start, + * files and daemons of threads that are no longer live go too. The service is off, answering + * `{available: false}`, without an absolute XDG_RUNTIME_DIR or while the Environment does not + * declare its browser (the Environment browser host's declaration). */ import { ThreadId } from "@t3tools/contracts"; @@ -38,6 +40,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import { forkParked } from "../serverActivation.ts"; import { agentBrowserSessionName } from "./agentBrowserSession.ts"; import { agentBrowserSocketDirectory, @@ -60,9 +63,10 @@ import { readSessionBindings, replaceOwnerFile, type SessionBinding, + stopSessionDaemon, watchDirectories, } from "./holders.ts"; -import { type LiveThread, ThreadLifecycle } from "./threadLifecycle.ts"; +import { type LiveThread, ThreadLifecycle, type ThreadLifecycleEvent } from "./threadLifecycle.ts"; /** At most this many tabs per thread: a hold past it is refused, never one dropped to make room. */ export const MAX_THREAD_TABS = 128; @@ -145,6 +149,8 @@ export interface ThreadTabsCoreSeams { readonly liveThread: (threadId: string) => Promise; /** Every live thread; rejects when they cannot be read. */ readonly liveThreads: () => Promise>; + /** Settles once the projection has caught up (command readiness). */ + readonly ready: Promise; readonly readFile?: ((path: string) => Promise) | undefined; readonly daemon?: DaemonSeams | undefined; readonly watch?: typeof watchDirectories | undefined; @@ -167,6 +173,8 @@ export interface ThreadTabsCore { tab: { readonly tabId: string; readonly view: string }, reveal: boolean, ) => void; + /** Archive, delete, unarchive and titles; never fails. */ + readonly lifecycle: (event: ThreadLifecycleEvent) => Promise; readonly subscribe: (threadId: string, listener: (event: ThreadTabsEvent) => void) => () => void; /** Reconciles, reads the other holders and the agents' bindings now (what the beat does). */ readonly refresh: () => Promise; @@ -198,6 +206,8 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { /** The last good read of each of T3 Code's holder files, by strip. */ const index = new Map(); + /** Strips of threads archived or deleted since the start: they take no hold. */ + const retired = new Set(); const reveals = new Map>(); /** Every holder file's holds, by tab: the other holders a snapshot names. */ let holdsOf = new Map(); @@ -311,9 +321,18 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { else index.set(strip, held); }; - /** Holds `tab` for `strip`, reconciling first; a tab held already keeps its time. */ - const holdTab = (strip: string, title: string, tab: string) => + /** + * The live thread `threadId` holds `tab`, reconciling first; a tab held already keeps its time. + * The thread is read live and not retired inside the queue, and `retired` once more right before + * the write: an archive (`retire`) marks the strip first and removes its file through the same + * queue, so whichever of the two comes second sees the other, and an archived thread's file is + * never written back. Refused with thread-not-found otherwise. + */ + const holdTab = (threadId: string, tab: string) => serialized(async () => { + const strip = stripOf(threadId); + const live = retired.has(strip) ? null : await liveThreadOf(threadId); + if (live === null) throw refusal("thread-not-found"); const held = await current(strip); const gone = letGo(held, await listAndBoot()); for (const id of gone) held.tabs.delete(id); @@ -323,7 +342,8 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { held.tabs.set(tab, await seams.bootClock()); changed = true; } - if (held.label === "") held.label = cutLabel(title); + if (held.label === "") held.label = cutLabel(live.title); + if (retired.has(strip)) throw refusal("thread-not-found"); if (changed) await write(strip, held); forgetReveals(strip, gone); remember(strip, held); @@ -441,6 +461,53 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { return threadId; } + async function stopDaemon(session: string): Promise { + if (seams.socketDirectory === undefined) return; + try { + const outcome = await stopSessionDaemon(seams.socketDirectory, session, seams.daemon); + if (outcome === "not-agent-browser") log("thread-tabs-daemon-not-agent-browser", { session }); + } catch (error) { + log("thread-tabs-daemon-stop-failed", { session, cause: String(error) }); + } + } + + /** Removes a strip's file even when it cannot be read: archive and delete let go of everything. */ + async function removeFile(strip: string): Promise { + try { + await NodeFSP.rm(holderPath(strip)); + } catch (error) { + if (!isMissing(error)) log("thread-tabs-remove-failed", { strip, cause: String(error) }); + } + index.delete(strip); + reveals.delete(strip); + } + + /** At the start: files and daemons of threads that are no longer live, including those archived + * while T3 Code was down or before this release. Names outside `t3-` are never touched. */ + async function sweep(): Promise { + let live: ReadonlyArray; + try { + live = await seams.liveThreads(); + } catch { + log("thread-tabs-sweep-skipped", { reason: "threads-unreadable" }); + return; + } + const liveStrips = new Set(live.map((thread) => stripOf(thread.id))); + for (const name of await directoryNames(holdersDirectory).catch(() => [])) { + if (!name.endsWith(".json")) continue; + const strip = name.slice(0, -".json".length); + if (!isThreadStrip(strip) || liveStrips.has(strip)) continue; + await removeFile(strip); + await stopDaemon(strip); + } + if (seams.socketDirectory === undefined) return; + for (const name of await directoryNames(seams.socketDirectory).catch(() => [])) { + if (!name.endsWith(".pid")) continue; + const session = name.slice(0, -".pid".length); + if (isThreadStrip(session) && !liveStrips.has(session)) await stopDaemon(session); + } + } + const nextRevealId = () => `${seams.epoch}:${++revealCount}`; const reveal = (strip: string, tab: string) => { const own = reveals.get(strip) ?? new Map(); @@ -534,14 +601,13 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { if (!available() || !isTargetId(tabId)) return; try { await loaded(); - const strip = stripOf(threadId); - const live = await seams.liveThread(threadId); - if (live === null) return; const tab = tabId.toUpperCase(); - await holdTab(strip, live.title, tab); - if (revealed) reveal(strip, tab); + await holdTab(threadId, tab); + if (revealed) reveal(stripOf(threadId), tab); publish(); } catch (error) { + // A thread that is gone, or went meanwhile, records nothing: no warning for that. + if (isThreadTabsError(error) && error.reason === "thread-not-found") return; log("thread-tabs-record-failed", { reason: isThreadTabsError(error) ? error.reason : String(error), }); @@ -583,8 +649,10 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { }; async function start(): Promise { + await seams.ready; if (stopped) return; await serialized(async () => { + await sweep(); await reconcileAll(); await readHolders(); await readBindings(true); @@ -602,6 +670,40 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { publish(); } + async function retire(threadId: string): Promise { + const strip = stripOf(threadId); + retired.add(strip); + for (const [session, cached] of threadOfSessionCache) + if (cached === threadId) threadOfSessionCache.delete(session); + // While the service is off, the next start's sweep lets go of it. + if (!available() || startup === undefined) return; + await startup; + await serialized(() => removeFile(strip)); + await stopDaemon(strip); + publish(); + } + + /** A title becomes the label of the thread's file, only where the file exists. */ + async function retitle(threadId: string, title: string): Promise { + if (!available() || startup === undefined) return; + await startup; + const strip = stripOf(threadId); + await serialized(async () => { + let held: Held; + try { + held = await current(strip); + } catch { + log("thread-tabs-label-skipped", { strip }); + return; + } + const label = cutLabel(title); + if (held.tabs.size === 0 || held.label === label) return; + held.label = label; + await write(strip, held); + remember(strip, held); + }).catch(() => log("thread-tabs-label-failed", { strip })); + } + return { declare(origin) { viewOrigin = origin; @@ -616,19 +718,18 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { if (!available()) return { available: false }; await loaded(); const strip = stripOf(threadId); - if ((await liveThreadOf(threadId)) === null) throw refusal("thread-not-found"); + if (retired.has(strip) || (await liveThreadOf(threadId)) === null) { + throw refusal("thread-not-found"); + } return snapshotFor(strip, threadId); }, async hold(threadId, tabId) { if (!isTargetId(tabId)) throw refusal("invalid-request"); if (!available()) throw refusal("unavailable"); await loaded(); - const strip = stripOf(threadId); - const live = await liveThreadOf(threadId); - if (live === null) throw refusal("thread-not-found"); - await holdTab(strip, live.title, tabId.toUpperCase()); + await holdTab(threadId, tabId.toUpperCase()); publish(); - return snapshotFor(strip, threadId); + return snapshotFor(stripOf(threadId), threadId); }, async release(threadId, tabId) { if (!isTargetId(tabId)) throw refusal("invalid-request"); @@ -651,6 +752,15 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { }; for (const { listener } of subscribers.get(stripOf(threadId)) ?? []) listener(event); }, + async lifecycle(event) { + try { + if (event.type === "retired") await retire(event.threadId); + else if (event.type === "restored") retired.delete(stripOf(event.threadId)); + else await retitle(event.threadId, event.title); + } catch (error) { + log("thread-tabs-lifecycle-failed", { type: event.type, cause: String(error) }); + } + }, subscribe(threadId, listener) { const strip = stripOf(threadId); const entry = { threadId, listener }; @@ -745,6 +855,10 @@ const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); const runFork = Effect.runForkWith(context); + let markReady = () => {}; + const ready = new Promise((resolve) => { + markReady = resolve; + }); const core = createThreadTabs({ runtimeDirectory, socketDirectory: agentBrowserSocketDirectory(env), @@ -753,6 +867,7 @@ const make = Effect.gen(function* () { liveThread: (threadId) => runPromise(lifecycle.liveThread(ThreadId.make(threadId)).pipe(Effect.map(Option.getOrNull))), liveThreads: () => runPromise(lifecycle.liveThreads), + ready, readFile: overrides.readFile, daemon: overrides.daemon, watch: overrides.watch, @@ -763,6 +878,11 @@ const make = Effect.gen(function* () { }, }); yield* Effect.addFinalizer(() => Effect.promise(() => core.stop())); + // After command readiness, so the projection has caught up before the sweep reads it. + yield* forkParked(lifecycle.ready.pipe(Effect.andThen(Effect.sync(() => markReady())))); + yield* forkParked( + Stream.runForEach(lifecycle.events, (event) => Effect.promise(() => core.lifecycle(event))), + ); const attempt = (work: () => Promise) => Effect.tryPromise({ try: work, catch: asError }); return ThreadTabs.of({ read: (threadId) => attempt(() => core.read(threadId)), diff --git a/apps/server/src/lazurio/threadTabsRoute.test.ts b/apps/server/src/lazurio/threadTabsRoute.test.ts index c080d1f17663..1eb6e4eb1224 100644 --- a/apps/server/src/lazurio/threadTabsRoute.test.ts +++ b/apps/server/src/lazurio/threadTabsRoute.test.ts @@ -59,6 +59,8 @@ const authLayer = EnvironmentAuth.layer.pipe( const lifecycleLayer = Layer.succeed( ThreadLifecycle, ThreadLifecycle.of({ + ready: Effect.void, + events: Stream.never, liveThread: (threadId) => Effect.succeed( threadId === threadA ? Option.some({ id: threadA, title: "Web" }) : Option.none(), From 6720786a248cb33d08ceade4c57f5845577036f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 04:38:00 +0200 Subject: [PATCH 4/8] feat(server): preview_open's tabs reach the web through the thread's strip, not its activities preview_open of another Lazurio Environment's view now tells the thread's devices through the thread's tab stream, a live-only foreign event revealed unless open: false, as every tab preview_open opens or adopts here already joins the thread's holder file. So the previewTab seam in upstream's ActivityPayloadProjection.ts, which carried preview_open's tab to the web inside the thread's activities, goes with its test: the file is upstream's again, and the 0.0.46 refresh, where upstream deletes it, has one conflict fewer. Co-Authored-By: Claude Opus 5.5 --- .../src/lazurio/environmentBrowser.test.ts | 18 ++- apps/server/src/lazurio/environmentBrowser.ts | 20 ++- .../src/lazurio/previewOpenActivity.test.ts | 142 ------------------ .../ActivityPayloadProjection.ts | 25 +-- 4 files changed, 33 insertions(+), 172 deletions(-) delete mode 100644 apps/server/src/lazurio/previewOpenActivity.test.ts diff --git a/apps/server/src/lazurio/environmentBrowser.test.ts b/apps/server/src/lazurio/environmentBrowser.test.ts index da5628b61dfe..1b0f180a38c5 100644 --- a/apps/server/src/lazurio/environmentBrowser.test.ts +++ b/apps/server/src/lazurio/environmentBrowser.test.ts @@ -186,6 +186,12 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray readonly tabId: string; readonly reveal: boolean; }> = []; + const foreignShown: Array<{ + readonly threadId: ThreadId; + readonly tabId: string; + readonly view: string; + readonly reveal: boolean; + }> = []; const declarations: Array = []; const threadTabs = ThreadTabs.ThreadTabs.of({ read: () => Effect.die("unused"), @@ -195,7 +201,8 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray declare: (viewOrigin) => Effect.sync(() => declarations.push(viewOrigin)), record: (thread, tabId, reveal) => Effect.sync(() => recorded.push({ threadId: thread, tabId, reveal })), - foreign: () => Effect.void, + foreign: (thread, tab, reveal) => + Effect.sync(() => foreignShown.push({ threadId: thread, ...tab, reveal })), }); const delivered: Array = []; const deliveryWaiters: Array<{ @@ -276,6 +283,7 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray registrations, reported, recorded, + foreignShown, declarations, setLink: (answer: CliAnswer) => { cli.link = answer; @@ -639,7 +647,13 @@ it.effect("answers preview_open of another Environment's view without this brows // Neither this Environment's browser nor its lazurio command was asked. expect(host.browser.connections).toHaveLength(0); expect(host.runs).toEqual([{ command: lazurio, args: ["browser", "link", "--json"] }]); - // No holder file records another Environment's tab. + // The thread's devices are told of each tab, live; no holder file records another + // Environment's tab. + expect(host.foreignShown).toEqual([ + { threadId, tabId: foreignId, view: foreignView, reveal: true }, + { threadId, tabId: personalId, view: personal, reveal: false }, + { threadId, tabId: foreignId, view: foreignView, reveal: true }, + ]); expect(host.recorded).toEqual([]); // The host's declaration is the strips' too. expect(host.declarations).toEqual(["https://browser.example.test"]); diff --git a/apps/server/src/lazurio/environmentBrowser.ts b/apps/server/src/lazurio/environmentBrowser.ts index 8bbfc019b2d5..90e312ee5790 100644 --- a/apps/server/src/lazurio/environmentBrowser.ts +++ b/apps/server/src/lazurio/environmentBrowser.ts @@ -21,9 +21,9 @@ * person sees, and recording is not available, so those are answered with what to do instead. * Another Environment's view of a tab (an agent working in that Environment over SSH) is never * loaded here: preview_open answers with that tab, which the person's right panel opens as the - * other Environment's, and says to drive it with agent-browser on that Environment. Any other - * operation on that tab fails with the same advice. The host's declaration of the browser is also - * the thread strips' (threadTabs.ts). + * other Environment's (a live-only `foreign` event of the thread's strip), and says to drive it + * with agent-browser on that Environment. Any other operation on that tab fails with the same + * advice. The host's declaration of the browser is also the thread strips' (threadTabs.ts). */ import { @@ -283,9 +283,10 @@ export const make = Effect.gen(function* () { }); /** * preview_open of another Environment's view touches neither this browser nor the thread's tabs: - * it answers with the other tab, which the person's right panel opens as that Environment's. + * it answers with the other tab, which the thread's devices add to the right panel as that + * Environment's, from a live-only event. */ - const foreignOpen = (view: LazurioBrowserView, visible: boolean) => + const foreignOpen = (threadId: ThreadId, view: LazurioBrowserView, visible: boolean) => Effect.sync(() => { foreignTabs.set(view.targetId, view); return { @@ -293,7 +294,11 @@ export const make = Effect.gen(function* () { view: view.view, message: foreignAnswer(view, visible), }; - }); + }).pipe( + Effect.tap(() => + threadTabs.foreign(threadId, { tabId: view.targetId, view: view.view }, visible), + ), + ); /** Like upstream's server host: explicit-tab routing finds the host that has the tab. */ const reportLiveTabs = Effect.suspend(() => @@ -502,7 +507,8 @@ export const make = Effect.gen(function* () { catch: toOperationError, }); const foreign = url === undefined ? undefined : foreignView(url); - if (foreign !== undefined) return yield* foreignOpen(foreign, reveals(input)); + if (foreign !== undefined) + return yield* foreignOpen(request.threadId, foreign, reveals(input)); const foreignHost = url === undefined ? undefined : foreignBrowserHost(url); if (url !== undefined && foreignHost !== undefined) { return yield* Effect.fail(foreignNavigation(url, foreignHost)); diff --git a/apps/server/src/lazurio/previewOpenActivity.test.ts b/apps/server/src/lazurio/previewOpenActivity.test.ts deleted file mode 100644 index 4b6fcaef994c..000000000000 --- a/apps/server/src/lazurio/previewOpenActivity.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { OrchestrationThreadActivity } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { projectActivityPayload } from "../orchestration/ActivityPayloadProjection.ts"; - -// Lazurio overlay (plan DEV-6646): clients get a thread's activities slimmed, an MCP result cut -// to one line, so the projection keeps the tab preview_open answered with for the web's right -// panel (apps/web/src/lazurio/browserUse.ts). - -const tabId = "C1CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; -const foreignId = "0123456789ABCDEF0123456789ABCDEF"; -const foreignView = `https://browser.vm-02.acme.lazurio.io/t/${foreignId}`; -const status = { - available: true, - visible: true, - tabId, - url: "https://example.com/", - title: "Example", - loading: false, -}; -const foreignStatus = { - available: true, - visible: true, - tabId: foreignId, - url: null, - title: null, - loading: false, - view: foreignView, - message: `Tab ${foreignId} is in the browser of another Environment (browser.vm-02.acme.lazurio.io).`, -}; - -function completed(payload: Record): OrchestrationThreadActivity { - return { - id: "activity-1", - tone: "tool", - kind: "tool.completed", - summary: "Open browser preview", - payload: { itemType: "mcp_tool_call", status: "completed", toolCallId: "call-1", ...payload }, - turnId: null, - createdAt: "2026-10-09T10:00:00.000Z", - } as unknown as OrchestrationThreadActivity; -} - -/** Codex: the MCP item with the call's arguments and the whole result. */ -const codex = (result: unknown, tool = "preview_open") => - completed({ - title: `t3-code · ${tool}`, - data: { - item: { - type: "mcpToolCall", - id: "call-1", - server: "t3-code", - tool, - status: "completed", - arguments: { url: "https://example.com/", reuseExistingTab: false }, - result: { - content: [{ type: "text", text: JSON.stringify(result) }], - structuredContent: result, - }, - }, - }, - }); - -/** Claude Code: the tool's name and input, and the tool_result block (ClaudeAdapter.ts). */ -const claude = (result: unknown, tool = "preview_open") => - completed({ - title: "Open browser preview", - data: { - toolName: `mcp__t3-code__${tool}`, - input: { url: foreignView }, - result: { - type: "tool_result", - tool_use_id: "toolu_1", - content: [{ type: "text", text: JSON.stringify(result) }], - }, - }, - }); - -const payloadOf = (activity: OrchestrationThreadActivity) => - activity.payload as Record; - -describe("a completed preview_open on its way to the clients", () => { - it("keeps the tab it answered with and whether the person was shown it", () => { - for (const activity of [codex(status), claude(status)]) { - const projected = projectActivityPayload(activity); - expect(payloadOf(projected)).toMatchObject({ - previewTab: { tabId, visible: true }, - toolIcon: { _tag: "website", pageUrl: "https://example.com/" }, - }); - expect(payloadOf(projected).previewTab).not.toHaveProperty("view"); - // The result itself is still cut to its first line. - expect(JSON.stringify(projected.payload)).not.toContain("structuredContent"); - // Projecting again keeps it. - expect(payloadOf(projectActivityPayload(projected)).previewTab).toEqual({ - tabId, - visible: true, - }); - } - expect(payloadOf(projectActivityPayload(codex({ ...status, visible: false })))).toMatchObject({ - previewTab: { tabId, visible: false }, - }); - }); - - it("keeps another Environment's view of the tab, and no page address for it", () => { - for (const activity of [codex(foreignStatus), claude(foreignStatus)]) { - const payload = payloadOf(projectActivityPayload(activity)); - expect(payload.previewTab).toEqual({ tabId: foreignId, visible: true, view: foreignView }); - expect(payload).not.toHaveProperty("toolIcon"); - } - }); - - it("keeps no tab of a failed call, another tool, or another server", () => { - const failed = codex(status); - for (const [name, activity] of [ - ["failed", { ...failed, payload: { ...payloadOf(failed), status: "failed" } }], - ["tool error", claude({ ...status, isError: true })], - [ - "error result", - completed({ - data: { - toolName: "mcp__t3-code__preview_open", - result: { type: "tool_result", is_error: true, content: JSON.stringify(status) }, - }, - }), - ], - ["navigate", codex(status, "preview_navigate")], - ["status", claude(status, "preview_status")], - [ - "another server", - completed({ data: { toolName: "mcp__other__preview_open", result: { content: "{}" } } }), - ], - ["no tab", codex({ ...status, tabId: null })], - ["no visibility", codex({ ...status, visible: "yes" })], - ["long tab", codex({ ...status, tabId: "x".repeat(129) })], - ] as const) { - expect([name, payloadOf(projectActivityPayload(activity)).previewTab]).toEqual([ - name, - undefined, - ]); - } - }); -}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 2b5ca12b4694..adb6306efc5e 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -292,34 +292,17 @@ function projectPreviewToolMetadata(data: Record, status: unkno } output = page; } - // Lazurio overlay (plan DEV-6646): the tab preview_open answered with, whether the person was - // shown it, and another Environment's view of it, for the web's right panel (apps/web/src/lazurio). - const previewTab = - name.endsWith("preview_open") && - typeof page?.tabId === "string" && - page.tabId.length <= 128 && - typeof page.visible === "boolean" - ? { - previewTab: { - tabId: page.tabId, - visible: page.visible, - ...(typeof page.view === "string" && page.view.length <= 4096 - ? { view: page.view } - : {}), - }, - } - : {}; const rawUrl = asTrimmedString( asRecord(page?.toolIcon)?.pageUrl ?? (/preview_(?:open|navigate|status|snapshot)$/.test(name) ? page?.url : undefined), ); - if (!rawUrl || rawUrl.length > 4096) return previewTab; + if (!rawUrl || rawUrl.length > 4096) return {}; try { const url = new URL(rawUrl); - if (url.protocol !== "http:" && url.protocol !== "https:") return previewTab; - return { ...previewTab, toolIcon: { _tag: "website", pageUrl: url.href } }; + if (url.protocol !== "http:" && url.protocol !== "https:") return {}; + return { toolIcon: { _tag: "website", pageUrl: url.href } }; } catch { - return previewTab; + return {}; } } From fd1b44c8141ab8f08cba7f70edd5753280076fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 04:38:46 +0200 Subject: [PATCH 5/8] feat(web): the right panel shows the thread's Environment browser tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Lazurio Environment the web client's right panel now shows the tabs the thread keeps on the server, the same on every device. It follows the thread's event stream and reconciles the strip to the list: new tabs at the end in the list's order, existing ones where each device has them (dragging stays each device's choice), another Environment's tabs each device's own. A page's new tab and a plain click on a link to a tab in the chat hold the tab for the thread. The person's x releases the thread's hold: a toast says whether the tab stays (an agent works in it, or Apps or another thread holds it) or ends, and Vrátit within the hub's grace holds it again; Close all is one toast. A tab whose view ended leaves at once. The panel opens by itself only on the server's new reveal request (preview_open, a new agent window), and only inline, no longer on every agent click. The strip marks a tab an agent works in or another place holds, and its tooltip and a line under the view say where else it is. Retired: browserUse.ts, the thread's own tab, the interim "+ Browser" new tabs at the view's bare origin, the web's copy of the session name, RightPanelTabs's titles prop and profile seam, and ChatView's Browser seams (Browser stays upstream's on the web). The store keeps one generic overlay action through upstream's automatic or user updates. The CI allowlist follows. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lazurio-fork-ci.yml | 22 +- apps/web/src/components/ChatView.tsx | 27 +- apps/web/src/components/RightPanelTabs.tsx | 23 +- .../LazurioEnvironmentBrowser.test.tsx | 963 +++++++----------- .../src/lazurio/LazurioEnvironmentBrowser.tsx | 540 +++++----- apps/web/src/lazurio/agentBrowserSession.ts | 37 - apps/web/src/lazurio/browserUse.test.ts | 524 ---------- apps/web/src/lazurio/browserUse.ts | 588 ----------- .../src/lazurio/environmentBrowser.test.ts | 367 +------ apps/web/src/lazurio/environmentBrowser.ts | 109 +- apps/web/src/lazurio/threadTabs.test.ts | 509 +++++++++ apps/web/src/lazurio/threadTabs.ts | 767 ++++++++++++++ apps/web/src/rightPanelStore.ts | 101 +- 13 files changed, 1958 insertions(+), 2619 deletions(-) delete mode 100644 apps/web/src/lazurio/agentBrowserSession.ts delete mode 100644 apps/web/src/lazurio/browserUse.test.ts delete mode 100644 apps/web/src/lazurio/browserUse.ts create mode 100644 apps/web/src/lazurio/threadTabs.test.ts create mode 100644 apps/web/src/lazurio/threadTabs.ts diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index c91ff967e446..71a3ac7899c2 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -78,25 +78,21 @@ jobs: apps/web/index.html apps/web/src/components/AppSidebarLayout.tsx # The Environment browser (root decision 0191, plan DEV-6646): the web client has no - # browser, so on a Lazurio Environment the right panel's Browser frames the view the - # Environment names at /.lazurio/browser.json, and a tab a page opens there becomes - # another tab of the panel. A surface kind of its own, its tabs and their titles, no - # desktop profile choice without the desktop preview, and the seam in ChatView.tsx - # (listed above) that enables Browser and keeps each tab's frame beside the surface - # in view. + # browser, so on a Lazurio Environment the right panel shows the thread's tabs of the + # Environment browser, each the view of one remote tab in a frame. The server keeps a + # thread's tabs and streams them; every device reconciles its strip to that list, and + # a tab a page opens, a link in the chat or Vrátit holds a tab. A surface kind of its + # own with its title, icon and tooltip in RightPanelTabs.tsx, one generic store action, + # and ChatView.tsx's one hook call and the wrapper that keeps each tab's frame beside + # the surface in view (listed above). apps/web/src/components/RightPanelTabs.tsx apps/web/src/lazurio/LazurioEnvironmentBrowser.test.tsx apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx - apps/web/src/lazurio/agentBrowserSession.ts apps/web/src/lazurio/environmentBrowser.test.ts apps/web/src/lazurio/environmentBrowser.ts + apps/web/src/lazurio/threadTabs.test.ts + apps/web/src/lazurio/threadTabs.ts apps/web/src/rightPanelStore.ts - # The right panel opens on the Environment browser when the thread's agent starts to - # use it, which these read from the thread's activities (plan DEV-6646). The seams are - # a hook call in ChatView.tsx and openProactive's surface type in rightPanelStore.ts - # (both listed above). - apps/web/src/lazurio/browserUse.test.ts - apps/web/src/lazurio/browserUse.ts # Another Lazurio Environment's view opens as a marked tab of the right panel (plan # DEV-6646). The parser of a view's address is a copy of the server's, whose Environment # browser host answers preview_open of such a view. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7e3cffa2d7d0..13995f28d4a5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -198,8 +198,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; import { LazurioRightPanelSurfaces, - useLazurioEnvironmentBrowser, - useLazurioEnvironmentBrowserFromChat, + useLazurioThreadTabs, } from "../lazurio/LazurioEnvironmentBrowser"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; @@ -2060,7 +2059,8 @@ export default function ChatView(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const environmentBrowser = useLazurioEnvironmentBrowser(activeThreadRef, rightPanelOpen); + // Lazurio overlay: the thread's Environment browser tabs in the panel (apps/web/src/lazurio). + useLazurioThreadTabs(activeThreadRef, { inlinePanel: !shouldUseRightPanelSheet }); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const activeTerminalDrawerPresence = usePanelPresence( @@ -2893,13 +2893,6 @@ export default function ChatView(props: ChatViewProps) { conversationProviderStatus.supportsConversationRollback !== false; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - // Lazurio overlay: the chat opens the Environment browser in the panel (apps/web/src/lazurio). - useLazurioEnvironmentBrowserFromChat({ - threadRef: activeThreadRef, - activities: threadActivities, - live: threadSyncPhase === null, - inlinePanel: !shouldUseRightPanelSheet, - }); const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; const workspaceMutationId = useMemo(() => { const activityId = latestWorkspaceMutationId(threadActivities); @@ -10305,7 +10298,6 @@ export default function ChatView(props: ChatViewProps) { desktopByTabId={activePreviewState.desktopByTabId} previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} - environmentBrowserTitles={environmentBrowser.titles} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} onRenameDevice={(surfaceId, title) => { @@ -10317,9 +10309,7 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={ - environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() - } + onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} @@ -10328,7 +10318,7 @@ export default function ChatView(props: ChatViewProps) { onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} onAddDevice={addDeviceSurface} - browserAvailable={isPreviewSupportedInRuntime() || environmentBrowser.available} + browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} @@ -10366,7 +10356,6 @@ export default function ChatView(props: ChatViewProps) { desktopByTabId={activePreviewState.desktopByTabId} previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} - environmentBrowserTitles={environmentBrowser.titles} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} onRenameDevice={(surfaceId, title) => { @@ -10378,9 +10367,7 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={ - environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() - } + onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} @@ -10389,7 +10376,7 @@ export default function ChatView(props: ChatViewProps) { onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} onAddDevice={addDeviceSurface} - browserAvailable={isPreviewSupportedInRuntime() || environmentBrowser.available} + browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5861aff4f1d8..a4c5191aad51 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -83,6 +83,7 @@ import { } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import * as Lazurio from "~/lazurio/LazurioEnvironmentBrowser"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { FaviconImage } from "./preview/PreviewFaviconIcon"; @@ -114,8 +115,6 @@ interface RightPanelTabsProps { */ previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap; - /** Lazurio overlay: the titles Environment browser tabs report, by surface id. */ - environmentBrowserTitles?: Readonly>; onActivate: (surface: RightPanelSurface) => void; onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; @@ -626,7 +625,6 @@ function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, terminalLabelsById: ReadonlyMap, - environmentBrowserTitles: Readonly> | undefined, ): string { switch (surface.kind) { case "diff": @@ -651,7 +649,7 @@ function surfaceTitle( case "device": return surface.title ?? surface.target?.name ?? "Device"; case "environment-browser": - return environmentBrowserTitles?.[surface.id] ?? "Browser"; + return Lazurio.surfaceTitle(surface); case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -736,7 +734,7 @@ function SurfaceIcon({ case "agents": return ; case "environment-browser": - return ; + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -903,10 +901,7 @@ function SortableTab({ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; - const browserDefaults = useBrowserDefaults(); - // Profiles belong to the desktop preview. Without it, an available Browser is the Lazurio - // Environment browser (apps/web/src/lazurio), which has none to choose from. - const browserProfiles = previewBridge ? browserDefaults.profiles : []; + const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); @@ -1014,6 +1009,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddDevice, }, ] as const; + const lazurio = Lazurio.useRightPanel(); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1216,7 +1212,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { surface, props.previewSessions, props.terminalLabelsById, - props.environmentBrowserTitles, ); const previewTabId = previewTabIdOf(surface, props.previewSessions); // Desktop state is keyed by the session id, but desktop actions @@ -1338,7 +1333,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { title={title} /> ) : ( - title + lazurio.tooltip(surface, title) )} @@ -1374,11 +1369,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // while hover or arrow reveals the profiles. The choice // lives at open time because a tab's profile is fixed then — // Electron only honours a partition before attach. - if ( - action.label === "Browser" && - action.available && - browserProfiles.length > 0 - ) { + if (action.label === "Browser" && action.available) { return ( ({ usePrimaryEnvironmentId: () => "env-1" })); @@ -26,25 +31,100 @@ const pageUrl = "https://t3code.vm-01.example.lazurio.io/"; const jsdom = (globalThis as unknown as { jsdom: { reconfigure(options: { url: string }): void } }) .jsdom; const viewOrigin = "https://browser.vm-01.example.lazurio.io"; -const pageTab = (target: string) => - ({ - id: `environment-browser:${target}`, - kind: "environment-browser", - view: `${viewOrigin}/t/${target}`, - }) as const; -const first = pageTab("0123456789abcdef".repeat(2)); -const second = pageTab("fedcba9876543210".repeat(2)); -const opened = pageTab("00112233445566778899aabbccddeeff"); +const id = (n: number) => n.toString(16).toUpperCase().padStart(32, "0"); +const tab = (n: number) => ({ id: tabSurfaceId(id(n)), kind: "environment-browser" }) as const; +const viewOf = (n: number) => `${viewOrigin}/t/${id(n)}`; const files = { id: "files", kind: "files" } as const; const threadA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); const threadB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); +// Another Environment's people's view of one of its tabs, where an agent works over SSH. +const foreignId = "fedcba9876543210".repeat(2); +const foreignOrigin = "https://browser.vm-02.acme.lazurio.io"; +const foreignView = `${foreignOrigin}/t/${foreignId}`; +const foreignTab = { + id: `environment-browser:${foreignId}`, + kind: "environment-browser", + view: foreignView, +} as const; + +const snapshot = ( + tabs: ReadonlyArray & { readonly tabId: string }>, + overrides: Partial = {}, +): ThreadTabsSnapshot => ({ + available: true, + epoch: "epoch-1", + revision: 1, + threadId: "thread-A", + strip: "t3-thread-A", + viewOrigin, + tabs: tabs.map((entry) => ({ + view: `${viewOrigin}/t/${entry.tabId}`, + agent: false, + alsoHeldBy: [], + ...entry, + })), + ...overrides, +}); +const panel = (threadRef: ScopedThreadRef = threadA) => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); +const setPanel = (state: ThreadRightPanelState) => + useRightPanelStore.setState({ byThreadKey: { [scopedThreadKey(threadA)]: state } }); + +/** The server's event stream, as the page's EventSource sees it. */ +class FakeEventSource { + static instances: FakeEventSource[] = []; + readonly listeners = new Map void>>(); + closed = false; + constructor(readonly url: string) { + FakeEventSource.instances.push(this); + } + addEventListener(type: string, listener: (event: MessageEvent) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + close() { + this.closed = true; + } + emit(type: string, data: unknown) { + act(() => { + for (const listener of this.listeners.get(type) ?? []) { + listener(new MessageEvent(type, { data: JSON.stringify(data) })); + } + }); + } +} let root: Root; let container: HTMLDivElement; +const posted: Array<{ readonly action: string; readonly body: unknown }> = []; +/** What the server's holder file of thread A holds, which its POST answers carry. */ +let held: ThreadTab[] = []; beforeEach(() => { jsdom.reconfigure({ url: pageUrl }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("EventSource", FakeEventSource); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + const action = url.split("/").at(-1) ?? ""; + const body = JSON.parse(String(init.body)) as { readonly tabId: string }; + posted.push({ action, body }); + held = + action === "hold" + ? held.some((entry) => entry.tabId === body.tabId) + ? held + : [...held, { tabId: body.tabId, view: "", agent: false, alsoHeldBy: [] }] + : held.filter((entry) => entry.tabId !== body.tabId); + return new Response(JSON.stringify(snapshot(held)), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); + FakeEventSource.instances = []; + posted.length = 0; + held = []; + resetThreadTabs(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -54,9 +134,20 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()); container.remove(); + vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); +const settled = async (threadRef: ScopedThreadRef = threadA) => { + const sync = syncOf(threadRef); + let queue: Promise; + do { + queue = sync.queue; + await queue; + } while (queue !== sync.queue); +}; + async function showPanel( threadRef: ScopedThreadRef, surfaces: readonly RightPanelSurface[], @@ -75,182 +166,130 @@ async function showPanel( ); } -function frameOf(surface: { readonly view: string }): HTMLIFrameElement { - const frame = container.querySelector(`iframe[src="${surface.view}"]`); - if (frame === null) throw new Error(`No frame shows ${surface.view}`); +function frameOf(view: string): HTMLIFrameElement { + const frame = container.querySelector(`iframe[src="${view}"]`); + if (frame === null) throw new Error(`No frame shows ${view}`); return frame; } const isHidden = (frame: HTMLIFrameElement) => frame.closest("[inert]") !== null; +const tell = (frame: HTMLIFrameElement, data: unknown, origin = viewOrigin) => + act(async () => { + window.dispatchEvent( + new MessageEvent("message", { data, origin, source: frame.contentWindow }), + ); + }); +const withSnapshot = (answer: ThreadTabsSnapshot | null, threadRef: ScopedThreadRef = threadA) => + useThreadTabsStore.setState((state) => ({ + snapshots: { ...state.snapshots, [scopedThreadKey(threadRef)]: answer }, + })); describe("the panel's Environment browser frames", () => { it("stay mounted while the panel shows the thread, only the active one in view", async () => { - const surfaces = [first, files, second]; - await showPanel(threadA, surfaces, first.id); - const [firstFrame, secondFrame] = [frameOf(first), frameOf(second)]; - expect([isHidden(firstFrame), isHidden(secondFrame)]).toEqual([false, true]); - - await showPanel(threadA, surfaces, second.id); - expect(frameOf(first)).toBe(firstFrame); - expect(frameOf(second)).toBe(secondFrame); - expect([isHidden(firstFrame), isHidden(secondFrame)]).toEqual([true, false]); - + withSnapshot(snapshot([{ tabId: id(1) }, { tabId: id(2) }])); + const surfaces = [tab(1), files, tab(2)]; + await showPanel(threadA, surfaces, tab(1).id); + const [first, second] = [frameOf(viewOf(1)), frameOf(viewOf(2))]; + expect([isHidden(first), isHidden(second)]).toEqual([false, true]); + await showPanel(threadA, surfaces, tab(2).id); + expect([frameOf(viewOf(1)), frameOf(viewOf(2))]).toEqual([first, second]); + expect([isHidden(first), isHidden(second)]).toEqual([true, false]); // Another kind of surface in view: both frames stay, both hidden. await showPanel(threadA, surfaces, files.id); expect(container.textContent).toContain("Files in view"); - expect(frameOf(first)).toBe(firstFrame); - expect(frameOf(second)).toBe(secondFrame); - expect([isHidden(firstFrame), isHidden(secondFrame)]).toEqual([true, true]); - + expect([isHidden(first), isHidden(second)]).toEqual([true, true]); // Closing a tab ends its frame and keeps the others. - await showPanel(threadA, [first, files], files.id); + await showPanel(threadA, [tab(1), files], files.id); expect(container.querySelectorAll("iframe")).toHaveLength(1); - expect(frameOf(first)).toBe(firstFrame); - // Another thread's panel has frames of its own. - await showPanel(threadB, [first], first.id); - expect(frameOf(first)).not.toBe(firstFrame); + withSnapshot(snapshot([{ tabId: id(1) }]), threadB); + await showPanel(threadB, [tab(1)], tab(1).id); + expect(frameOf(viewOf(1))).not.toBe(first); }); - it("puts a page's new tab in front only from the tab in view", async () => { - const key = scopedThreadKey(threadA); - useRightPanelStore.setState({ - byThreadKey: { - [key]: { isOpen: true, activeSurfaceId: first.id, surfaces: [first, second] }, - }, - }); - await showPanel(threadA, [first, second], first.id); - const announce = (frame: HTMLIFrameElement) => - act(async () => { - window.dispatchEvent( - new MessageEvent("message", { - data: { type: "lazurio-browser:new-tab", view: opened.view, url: "https://a.example/" }, - origin: viewOrigin, - source: frame.contentWindow, - }), - ); - }); - const panel = () => - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadA); + it("wait for the server's view origin, and say when the Environment offers no browser", async () => { + await showPanel(threadA, [tab(1)], tab(1).id); + expect(container.querySelector("iframe")).toBeNull(); + expect(container.textContent).not.toContain("Browser unavailable"); + act(() => withSnapshot(null)); + expect(container.textContent).toContain("Browser unavailable"); + }); - // From the hidden tab: it joins the panel behind the tab in view. - await announce(frameOf(second)); - expect(panel()).toEqual({ - isOpen: true, - activeSurfaceId: first.id, - surfaces: [first, second, opened], - }); - expect(useRightPanelStore.getState().getUserActionRevision(threadA)).toBe(0); + it("leave the desktop app's panel as it is", async () => { + vi.stubGlobal("desktopBridge", { preview: {} }); + await showPanel(threadA, [tab(1), files], files.id); + expect(container.querySelectorAll("iframe")).toHaveLength(0); + expect(container.innerHTML).toBe("

Files in view

"); + }); - // From the tab in view: it comes to the front, as in the person's browser. - useRightPanelStore.setState({ - byThreadKey: { - [key]: { isOpen: true, activeSurfaceId: first.id, surfaces: [first, second] }, - }, + it("take the title a frame tells, hold a page's new tab in front or behind, and drop a tab that ended", async () => { + const listed = snapshot([{ tabId: id(1) }, { tabId: id(2) }]); + held = [...listed.tabs]; + withSnapshot(listed); + setPanel({ isOpen: true, activeSurfaceId: tab(1).id, surfaces: [tab(1), tab(2)] }); + await showPanel(threadA, [tab(1), tab(2)], tab(1).id); + await tell(frameOf(viewOf(1)), { + type: "lazurio-browser:info", + url: "https://example.com/docs", + title: "Docs", + view: viewOf(1), }); - await announce(frameOf(first)); + expect(useThreadTabsStore.getState().titles[tab(1).id]).toBe("Docs"); + // From the hidden tab: it joins the panel behind the tab in view. + await tell(frameOf(viewOf(2)), { type: "lazurio-browser:new-tab", view: viewOf(3), url: "x" }); expect(panel()).toEqual({ isOpen: true, - activeSurfaceId: opened.id, - surfaces: [first, second, opened], + activeSurfaceId: tab(1).id, + surfaces: [tab(1), tab(2), tab(3)], }); + // From the tab in view: in front, as in the person's browser. + await tell(frameOf(viewOf(1)), { type: "lazurio-browser:new-tab", view: viewOf(4), url: "x" }); + expect(panel().activeSurfaceId).toBe(tab(4).id); + await settled(); + expect(posted.map((entry) => [entry.action, (entry.body as { tabId: string }).tabId])).toEqual([ + ["hold", id(3)], + ["hold", id(4)], + ]); + // A tab that ended leaves at once and is let go of; an older snapshot does not bring it back. + posted.length = 0; + await tell(frameOf(viewOf(2)), { type: "lazurio-browser:closed", view: viewOf(2) }); + expect(panel().surfaces.map((surface) => surface.id)).not.toContain(tab(2).id); + await settled(); + expect(posted).toEqual([{ action: "release", body: { threadId: "thread-A", tabId: id(2) } }]); }); - it("frame a new tab at the view's bare origin and keep that frame once the view tells its tab", async () => { - const key = scopedThreadKey(threadA); - const added = { - id: "environment-browser:new:1", - kind: "environment-browser", - view: `${viewOrigin}/`, - } as const; - useRightPanelStore.setState({ - byThreadKey: { [key]: { isOpen: true, activeSurfaceId: added.id, surfaces: [added] } }, - }); - await showPanel(threadA, [added], added.id); - const frame = frameOf(added); - // The view opened a remote tab, moved to its address and tells it. + it("ignore messages from another window, another origin, or another type", async () => { + withSnapshot(snapshot([{ tabId: id(1) }])); + setPanel({ isOpen: true, activeSurfaceId: tab(1).id, surfaces: [tab(1)] }); + await showPanel(threadA, [tab(1)], tab(1).id); + const newTab = { type: "lazurio-browser:new-tab", view: viewOf(3), url: "x" }; await act(async () => { window.dispatchEvent( - new MessageEvent("message", { - data: { type: "lazurio-browser:info", url: "about:blank", title: "", view: opened.view }, - origin: viewOrigin, - source: frame.contentWindow, - }), + new MessageEvent("message", { data: newTab, origin: viewOrigin, source: window }), ); }); - const settled = { ...added, view: opened.view }; - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadA).surfaces, - ).toEqual([settled]); - // The settled tab keeps its frame, which the view already moved; nothing loads again. - await showPanel(threadA, [settled], settled.id); - expect(container.querySelectorAll("iframe")).toHaveLength(1); - expect(container.querySelector("iframe")).toBe(frame); - expect(frame.getAttribute("src")).toBe(`${viewOrigin}/`); - expect(container.querySelector('a[target="_blank"]')?.getAttribute("href")).toBe(opened.view); + await tell(frameOf(viewOf(1)), newTab, "https://evil.example"); + await tell(frameOf(viewOf(1)), { ...newTab, type: "lazurio-browser:open" }); + expect(panel().surfaces).toEqual([tab(1)]); + expect(posted).toEqual([]); }); - it("leave the desktop app's panel as it is", async () => { - vi.stubGlobal("desktopBridge", { preview: {} }); - await showPanel(threadA, [first, files], files.id); - expect(container.querySelectorAll("iframe")).toHaveLength(0); - expect(container.innerHTML).toBe("

Files in view

"); - }); -}); - -// Another Environment's people's view of one of its tabs, where an agent works over SSH. -const foreignId = "fedcba9876543210".repeat(2); -const foreignOrigin = "https://browser.vm-02.acme.lazurio.io"; -const foreignView = `${foreignOrigin}/t/${foreignId}`; -const foreignTab = { - id: `environment-browser:${foreignId}`, - kind: "environment-browser", - view: foreignView, -} as const; - -describe("the panel's + Browser", () => { - const ownTab = `${viewOrigin}/t/${"8A3F0C2D4E5B6A79".repeat(2)}`; - // What the Environment answers at /.lazurio/browser.json on this page's origin. - const answer = () => { - const body = JSON.stringify({ available: true, view: ownTab, session: null }); - const response = new Response(body, { headers: { "content-type": "application/json" } }); - Object.defineProperty(response, "url", { - value: `${window.location.origin}/.lazurio/browser.json`, - }); - return response; - }; - const panel = () => - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadA); - - it("opens the thread's own tab first, then a new remote tab of the view each time", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => answer()), + it("say discreetly under the view's bar where else the tab is", async () => { + withSnapshot( + snapshot([ + { + tabId: id(1), + alsoHeldBy: [ + { strip: "launchpad", label: "Launchpad" }, + { strip: "t3-thread-B", label: "Web shop" }, + ], + }, + ]), + ); + await showPanel(threadA, [tab(1)], tab(1).id); + expect(container.querySelector('[role="note"]')?.textContent).toBe( + "Shared tab · also in Apps and in the thread “Web shop”", ); - function Panel() { - const browser = useLazurioEnvironmentBrowser(threadA, true); - return ( - - ); - } - await act(async () => root.render()); - await act(() => new Promise((resolve) => setTimeout(resolve, 0))); - const button = container.querySelector("button"); - if (button === null || button.disabled) throw new Error("The Environment offered no browser"); - const press = () => act(() => button.click()); - - press(); - expect(panel().surfaces).toEqual([{ id: "environment-browser", kind: "environment-browser" }]); - press(); - press(); - const [, first, second] = panel().surfaces; - for (const tab of [first, second]) { - expect(tab).toMatchObject({ kind: "environment-browser", view: `${viewOrigin}/` }); - } - expect(second?.id).not.toBe(first?.id); - expect(panel()).toMatchObject({ isOpen: true, activeSurfaceId: second?.id }); }); }); @@ -258,491 +297,192 @@ describe("another Environment's browser in the panel", () => { const signInNotice = "Sign in to the Environment browser of Acme · vm-02"; it("frames it marked as that Environment's, telling it only this page's origin", async () => { - await showPanel(threadA, [first, foreignTab], foreignTab.id); - // That Environment's view lets T3 frame it by the origin its request names (jsdom has the - // attribute only). - expect(frameOf(foreignTab).getAttribute("referrerpolicy")).toBe("origin"); - expect(frameOf(first).getAttribute("referrerpolicy")).toBe("origin"); - // Named by its Organization and Environment, with the Organization's picture. - expect(frameOf(foreignTab).title).toBe("Acme · vm-02"); + withSnapshot(snapshot([{ tabId: id(1) }])); + await showPanel(threadA, [tab(1), foreignTab], foreignTab.id); + expect(frameOf(foreignView).getAttribute("referrerpolicy")).toBe("origin"); + expect(frameOf(foreignView).title).toBe("Acme · vm-02"); expect(container.textContent).toContain("Acme · vm-02"); const picture = container.querySelector( 'img[src="https://github.com/acme.png?size=32"]', ); expect(picture?.getAttribute("referrerpolicy")).toBe("no-referrer"); - // A picture that does not load leaves the Organization's initial. act(() => { picture?.dispatchEvent(new Event("error")); }); - expect(container.querySelector("img")).toBeNull(); expect(container.textContent).toContain("AAcme · vm-02"); - // This Environment's own tab stays as it was. - expect(frameOf(first).title).toBe("Environment browser"); + expect(frameOf(viewOf(1)).title).toBe("Environment browser"); }); it("asks the person to sign in there where its view does not come up", async () => { - vi.useFakeTimers(); - try { - await showPanel(threadA, [foreignTab], foreignTab.id); - const notice = () => container.textContent?.includes(signInNotice) ?? false; - // The gateway sends the frame to its sign-in, which does not render in a frame. - act(() => { - frameOf(foreignTab).dispatchEvent(new Event("load")); - }); - act(() => vi.advanceTimersByTime(3_999)); - expect(notice()).toBe(false); - act(() => vi.advanceTimersByTime(1)); - expect(notice()).toBe(true); - // The way out: the view in a tab of its own, where the sign-in renders, then Reload. - expect( - [...container.querySelectorAll(`a[href="${foreignView}"]`)].map( - (link) => [link.target, link.rel], - ), - ).toEqual([ - ["_blank", "noopener"], - ["_blank", "noopener"], - ]); - const before = frameOf(foreignTab); - const reload = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Reload", - ); - act(() => reload?.click()); - expect(frameOf(foreignTab)).not.toBe(before); - expect(notice()).toBe(false); - // A frame that never even loads gets the notice too, later. - act(() => vi.advanceTimersByTime(14_999)); - expect(notice()).toBe(false); - act(() => vi.advanceTimersByTime(1)); - expect(notice()).toBe(true); - // Its view comes up after all and tells its tab: the notice goes. - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: "lazurio-browser:info", - url: "https://example.com/", - title: "Example", - view: foreignView, - }, - origin: foreignOrigin, - source: frameOf(foreignTab).contentWindow, - }), - ); - }); - expect(notice()).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("does not ask while its view comes up, nor for this Environment's own tab", async () => { - vi.useFakeTimers(); - try { - await showPanel(threadA, [first, foreignTab], foreignTab.id); - act(() => { - frameOf(foreignTab).dispatchEvent(new Event("load")); - frameOf(first).dispatchEvent(new Event("load")); - window.dispatchEvent( - new MessageEvent("message", { - data: { type: "lazurio-browser:info", url: "https://a.example/", title: "A" }, - origin: foreignOrigin, - source: frameOf(foreignTab).contentWindow, - }), - ); - }); - act(() => vi.advanceTimersByTime(60_000)); - expect(container.textContent).not.toContain(signInNotice); - expect(container.textContent).not.toContain("Sign in"); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe("the chat and the Environment browser", () => { - const ownTab = `${viewOrigin}/t/${"8A3F0C2D4E5B6A79".repeat(2)}`; - const browse = (id: string, second: number, call = id, kind = "tool.started") => ({ - id, - kind, - turnId: "turn-1", - createdAt: `2026-10-08T10:00:${String(second).padStart(2, "0")}.000Z`, - payload: { - itemType: "command_execution", - toolCallId: call, - data: { command: "agent-browser --cdp 9222 snapshot -i" }, - }, - }); - // What the Environment answers at /.lazurio/browser.json on this page's origin. - const answer = () => { - const body = JSON.stringify({ available: true, view: ownTab, session: null }); - const response = new Response(body, { headers: { "content-type": "application/json" } }); - Object.defineProperty(response, "url", { - value: `${window.location.origin}/.lazurio/browser.json`, - }); - return response; - }; - const panel = () => - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadA); - - function Chat(props: { - readonly activities: ReadonlyArray; - readonly live?: boolean; - readonly inlinePanel?: boolean; - }) { - useLazurioEnvironmentBrowserFromChat({ - threadRef: threadA, - activities: props.activities, - live: props.live ?? true, - inlinePanel: props.inlinePanel ?? true, + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await showPanel(threadA, [foreignTab], foreignTab.id); + const notice = () => container.textContent?.includes(signInNotice) ?? false; + act(() => { + frameOf(foreignView).dispatchEvent(new Event("load")); }); - return ( - <> -
- Not in the chat - + act(() => vi.advanceTimersByTime(3_999)); + expect(notice()).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(notice()).toBe(true); + const before = frameOf(foreignView); + const reload = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Reload", ); - } - const showChat = (props: Parameters[0]) => - act(async () => root.render()); - // The Environment's answer and the opening it leads to. - const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); - - it("opens the panel on the thread's own tab when its agent starts to use the browser", async () => { - const fetch = vi.fn(async () => answer()); - vi.stubGlobal("fetch", fetch); - const history = [browse("before", 1)]; - await showChat({ activities: history }); - await settle(); - expect(fetch).not.toHaveBeenCalled(); - expect(panel().isOpen).toBe(false); - - const started = [...history, browse("call", 2)]; - await showChat({ activities: started }); - await settle(); - expect(fetch).toHaveBeenCalledTimes(1); - expect(panel()).toEqual({ - isOpen: true, - activeSurfaceId: "environment-browser", - surfaces: [{ id: "environment-browser", kind: "environment-browser" }], - }); - // The app opened it, not the person. - expect(useRightPanelStore.getState().getUserActionRevision(threadA)).toBe(0); - - // The person closes it: the call going on leaves it closed, the agent's next call opens it. - act(() => useRightPanelStore.getState().close(threadA)); - const ended = [...started, browse("call-done", 3, "call", "tool.completed")]; - await showChat({ activities: ended }); - await settle(); - expect(panel().isOpen).toBe(false); - await showChat({ activities: [...ended, browse("next", 4)] }); - await settle(); - expect(panel().isOpen).toBe(true); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("leaves the panel for the backlog, on a narrow screen, without the browser, and while it shows", async () => { - const fetch = vi.fn(async () => answer()); - vi.stubGlobal("fetch", fetch); - // A thread that loads: its backlog arrives before it is live. - await showChat({ activities: [], live: false }); - await showChat({ activities: [browse("a", 1)], live: false }); - await showChat({ activities: [browse("a", 1)] }); - // On a narrow screen the panel is a sheet over the chat and its composer. - await showChat({ activities: [browse("a", 1), browse("b", 2)], inlinePanel: false }); - await settle(); - expect(fetch).not.toHaveBeenCalled(); - expect(panel().isOpen).toBe(false); - - // An Environment without the browser: T3 answers its own page. - fetch.mockImplementation( - async () => new Response("", { headers: { "content-type": "text/html" } }), + act(() => reload?.click()); + expect(frameOf(foreignView)).not.toBe(before); + expect(notice()).toBe(false); + // Its view comes up after all and tells its tab: no notice. + await tell( + frameOf(foreignView), + { + type: "lazurio-browser:info", + url: "https://example.com/", + title: "Example", + view: foreignView, + }, + foreignOrigin, ); - await showChat({ activities: [browse("a", 1), browse("b", 2), browse("c", 3)] }); - await settle(); - expect(fetch).toHaveBeenCalledTimes(1); - expect(panel().isOpen).toBe(false); - - // The person picked the Environment browser already: nothing to ask. - act(() => useRightPanelStore.getState().open(threadA, "environment-browser")); - await showChat({ - activities: [browse("a", 1), browse("b", 2), browse("c", 3), browse("d", 4)], - }); - await settle(); - expect(fetch).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(60_000)); + expect(notice()).toBe(false); }); +}); - it("does not open over a choice the person made while the Environment was asked", async () => { - let respond = () => {}; - vi.stubGlobal( - "fetch", - vi.fn(() => new Promise((resolve) => (respond = () => resolve(answer())))), +describe("the chat's hook", () => { + function Chat(props: { readonly threadRef: ScopedThreadRef; readonly inlinePanel?: boolean }) { + useLazurioThreadTabs(props.threadRef, { inlinePanel: props.inlinePanel ?? true }); + return ( + ); - await showChat({ activities: [] }); - await showChat({ activities: [browse("a", 1)] }); - act(() => useRightPanelStore.getState().open(threadA, "files")); - respond(); - await settle(); - expect(panel()).toMatchObject({ isOpen: true, activeSurfaceId: "files" }); - }); - - it("opens a link to one remote tab of the Environment's view in the panel on a plain click", async () => { - const fetch = vi.fn(async () => answer()); - vi.stubGlobal("fetch", fetch); - // The agent's browser use: the Environment names its view. - await showChat({ activities: [] }); - await showChat({ activities: [browse("a", 1)] }); - await settle(); - act(() => useRightPanelStore.getState().close(threadA)); - const [agentTab, anotherView, outside] = [...container.querySelectorAll("a")]; - // What the browser would do with a click the panel leaves alone. - let followed: string | null = null; - const follow = (event: MouseEvent) => { - followed = (event.target as HTMLAnchorElement).href; - event.preventDefault(); - }; - document.addEventListener("click", follow); - const click = ( - link: HTMLAnchorElement | undefined, - init: MouseEventInit = {}, - ): string | null => { - followed = null; - act(() => { - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init })); - }); - return followed; - }; - try { - for (const gesture of [ - { ctrlKey: true }, - { metaKey: true }, - { shiftKey: true }, - { altKey: true }, - ]) { - expect(click(agentTab, gesture)).toBe(opened.view); - } - expect(click(anotherView)).toBe(anotherView?.href); - expect(click(outside)).toBe(second.view); - expect(panel().isOpen).toBe(false); + } + const showChat = (threadRef: ScopedThreadRef = threadA, inlinePanel = true) => + act(async () => root.render()); + const stream = () => { + const source = FakeEventSource.instances.at(-1); + if (source === undefined) throw new Error("No event stream"); + return source; + }; - expect(click(agentTab)).toBeNull(); - expect(panel()).toEqual({ - isOpen: true, - activeSurfaceId: opened.id, - surfaces: [{ id: "environment-browser", kind: "environment-browser" }, opened], - }); - } finally { - document.removeEventListener("click", follow); - } + it("follows the thread's list from the server's stream, and closes it with the view", async () => { + await showChat(); + expect(stream().url).toBe("/api/lazurio/thread-tabs/events?threadId=thread-A"); + expect(useThreadTabsStore.getState().inView).toEqual(threadA); + stream().emit("snapshot", snapshot([{ tabId: id(1) }, { tabId: id(2) }])); + expect(panel()).toMatchObject({ surfaces: [tab(1), tab(2)], isOpen: false }); + stream().emit("snapshot", snapshot([{ tabId: id(2) }])); + expect(panel().surfaces).toEqual([tab(2)]); + // Unavailable leaves the strip as it is. + stream().emit("snapshot", { available: false }); + expect(panel().surfaces).toEqual([tab(2)]); + const source = stream(); + await act(async () => root.render(

Elsewhere

)); + expect(source.closed).toBe(true); + expect(useThreadTabsStore.getState().inView).toBeNull(); }); - // A call of preview_open as Codex reports it: the call's arguments, and once it completes the - // tab the server keeps for clients (`previewTab`; its result is cut to one line). - const at = (second: number) => `2026-10-08T10:00:${String(second).padStart(2, "0")}.000Z`; - const previewOpen = ( - id: string, - second: number, - options: { - readonly call?: string; - readonly args?: Record; - readonly tab?: { readonly tabId: string; readonly visible: boolean; readonly view?: string }; - } = {}, - ): ThreadActivity => ({ - id, - kind: options.tab === undefined ? "tool.started" : "tool.completed", - turnId: "turn-1", - createdAt: at(second), - payload: { - itemType: "mcp_tool_call", - status: options.tab === undefined ? "inProgress" : "completed", - toolCallId: options.call ?? id, - title: "t3-code · preview_open", - ...(options.tab === undefined ? {} : { previewTab: options.tab }), - data: { - item: { - type: "mcpToolCall", - server: "t3-code", - tool: "preview_open", - arguments: options.args ?? {}, - }, - }, - }, + it("asks nothing for a thread of another environment, or in the desktop app", async () => { + await showChat(scopeThreadRef("env-2" as EnvironmentId, ThreadId.make("thread-C"))); + expect(FakeEventSource.instances).toHaveLength(0); + vi.stubGlobal("desktopBridge", { preview: {} }); + await showChat(); + expect(FakeEventSource.instances).toHaveLength(0); }); - const tabAt = (target: string) => - ({ - id: `environment-browser:${target}`, - kind: "environment-browser", - view: `${viewOrigin}/t/${target}`, - }) as const; - const own = { id: "environment-browser", kind: "environment-browser" } as const; - it("brings the tab a completed preview_open answered with to the front (#48)", async () => { - const fetch = vi.fn(async () => answer()); - vi.stubGlobal("fetch", fetch); - const [window1, window2] = ["C1".padEnd(32, "C"), "C2".padEnd(32, "C")]; - const newWindow = { reuseExistingTab: false, url: "https://example.com/" }; - // A call that completed before the thread was in view opens nothing. - const history = [previewOpen("old", 1, { tab: { tabId: window2, visible: true } })]; - await showChat({ activities: history }); - await settle(); - expect(fetch).not.toHaveBeenCalled(); + it("opens the inline panel on a tab an agent revealed after the baseline, never a sheet", async () => { + await showChat(); + stream().emit("snapshot", snapshot([{ tabId: id(1), revealRequest: { id: "epoch-1:1" } }])); expect(panel().isOpen).toBe(false); - - // The agent opens another window: as it starts, the thread's own tab; once it answers, its - // own window comes to the front. - const started = [...history, previewOpen("start", 2, { call: "new", args: newWindow })]; - await showChat({ activities: started }); - await settle(); - expect(panel()).toEqual({ isOpen: true, activeSurfaceId: own.id, surfaces: [own] }); - const answered = [ - ...started, - previewOpen("done", 3, { - call: "new", - args: newWindow, - tab: { tabId: window1, visible: true }, - }), - ]; - await showChat({ activities: answered }); - await settle(); - expect(panel()).toEqual({ - isOpen: true, - activeSurfaceId: tabAt(window1).id, - surfaces: [own, tabAt(window1)], - }); - expect(useRightPanelStore.getState().getUserActionRevision(threadA)).toBe(0); - - // Background work joins the panel behind the tab in view. - const background = [ - ...answered, - previewOpen("bg", 4, { args: { open: false }, tab: { tabId: window2, visible: false } }), - ]; - await showChat({ activities: background }); - await settle(); - expect(panel()).toEqual({ - isOpen: true, - activeSurfaceId: tabAt(window1).id, - surfaces: [own, tabAt(window1), tabAt(window2)], - }); - - // The thread's own window is the thread's own tab. - const ownWindow = ownTab.slice(ownTab.lastIndexOf("/") + 1); - await showChat({ - activities: [ - ...background, - previewOpen("again", 5, { tab: { tabId: ownWindow, visible: true } }), - ], - }); - await settle(); - expect(panel()).toMatchObject({ isOpen: true, activeSurfaceId: own.id }); - expect(panel().surfaces).toHaveLength(3); - }); - - it("brings no tab to the front over the person's choice or over the chat", async () => { - const asked: Array<() => void> = []; - const fetch = vi.fn( - () => new Promise((resolve) => asked.push(() => resolve(answer()))), + stream().emit( + "snapshot", + snapshot([ + { tabId: id(1), revealRequest: { id: "epoch-1:1" } }, + { tabId: id(2), revealRequest: { id: "epoch-1:2" } }, + ]), ); - vi.stubGlobal("fetch", fetch); - const window1 = "C1".padEnd(32, "C"); - await showChat({ activities: [] }); - await showChat({ - activities: [previewOpen("done", 1, { tab: { tabId: window1, visible: true } })], - }); - // The person picks the files while the Environment is asked. - act(() => useRightPanelStore.getState().open(threadA, "files")); - expect(fetch).toHaveBeenCalled(); - for (const respond of asked) respond(); - await settle(); - expect(panel()).toMatchObject({ isOpen: true, activeSurfaceId: "files" }); - expect(panel().surfaces.map((surface) => surface.id)).not.toContain(tabAt(window1).id); - - // Where the panel is a sheet over the chat, another Environment's tab joins it closed. + expect(panel()).toMatchObject({ isOpen: true, activeSurfaceId: tab(2).id }); act(() => useRightPanelStore.getState().close(threadA)); - await showChat({ - activities: [ - previewOpen("done", 1, { tab: { tabId: window1, visible: true } }), - previewOpen("foreign", 2, { - args: { url: foreignView }, - tab: { tabId: foreignId, visible: true, view: foreignView }, - }), - ], - inlinePanel: false, - }); - await settle(); - expect(panel()).toMatchObject({ isOpen: false, activeSurfaceId: "files" }); - expect(panel().surfaces).toContainEqual(foreignTab); + await showChat(threadA, false); + stream().emit("snapshot", snapshot([{ tabId: id(2), revealRequest: { id: "epoch-1:3" } }])); + expect(panel().isOpen).toBe(false); }); - it("opens another Environment's tab from preview_open without this Environment's browser", async () => { - const fetch = vi.fn(async () => answer()); - vi.stubGlobal("fetch", fetch); - const call = { call: "foreign", args: { url: foreignView } }; - await showChat({ activities: [] }); - // Starting a call at another Environment's view does not use this Environment's browser. - const started = [previewOpen("start", 1, call)]; - await showChat({ activities: started }); - await settle(); - expect(panel().isOpen).toBe(false); - await showChat({ - activities: [ - ...started, - previewOpen("done", 2, { - ...call, - tab: { tabId: foreignId, visible: true, view: foreignView }, - }), - ], - }); - await settle(); + it("adds another Environment's tab the server announces, in front when revealed", async () => { + await showChat(); + stream().emit("snapshot", snapshot([])); + stream().emit("foreign", { tabId: foreignId.toUpperCase(), view: foreignView }); + expect(panel()).toMatchObject({ isOpen: false, surfaces: [foreignTab] }); + act(() => useRightPanelStore.getState().closeSurface(threadA, foreignTab.id)); + stream().emit("foreign", { tabId: foreignId, view: foreignView, revealRequest: { id: "e:1" } }); expect(panel()).toEqual({ isOpen: true, activeSurfaceId: foreignTab.id, surfaces: [foreignTab], }); - // A view that names another tab than the answer is not taken. - act(() => useRightPanelStore.getState().close(threadA)); - await showChat({ - activities: [ - ...started, - previewOpen("done", 2, { - ...call, - tab: { tabId: foreignId, visible: true, view: foreignView }, - }), - previewOpen("odd", 3, { - args: { url: foreignView }, - tab: { tabId: "0".repeat(32), visible: true, view: foreignView }, - }), - ], + expect(posted).toEqual([]); + }); + + it("lets go of tabs the person closes in the strip, one toast for Close all, nothing for the list's own changes", async () => { + const toasts = vi.spyOn(toastManager, "add"); + await showChat(); + const listed = snapshot([ + { tabId: id(1), agent: true }, + { tabId: id(2), alsoHeldBy: [{ strip: "launchpad", label: "Launchpad" }] }, + { tabId: id(3) }, + ]); + held = [...listed.tabs]; + stream().emit("snapshot", listed); + act(() => useRightPanelStore.getState().activateSurface(threadA, tab(1).id)); + act(() => useRightPanelStore.getState().closeSurface(threadA, tab(1).id)); + await act(async () => undefined); + await settled(); + expect(posted).toEqual([{ action: "release", body: { threadId: "thread-A", tabId: id(1) } }]); + expect(toasts.mock.calls.at(-1)?.[0]).toMatchObject({ + title: "The tab left this thread.", + description: "An agent works in it, so it stays open.", }); - await settle(); - expect(panel().isOpen).toBe(false); - expect(fetch).not.toHaveBeenCalled(); + // Close all closes one surface at a time: one toast that restores all. + posted.length = 0; + toasts.mockClear(); + act(() => { + for (const surface of panel().surfaces) { + useRightPanelStore.getState().closeSurface(threadA, surface.id); + } + }); + await act(async () => undefined); + await settled(); + expect(toasts).toHaveBeenCalledTimes(1); + expect(posted.map((entry) => (entry.body as { tabId: string }).tabId).sort()).toEqual([ + id(2), + id(3), + ]); + const undo = toasts.mock.calls[0]?.[0].actionProps as { onClick: () => void } | undefined; + act(() => undo?.onClick()); + expect(panel().surfaces).toEqual([tab(2), tab(3)]); + // A tab the list lost leaves without a word. + posted.length = 0; + toasts.mockClear(); + await settled(); + posted.length = 0; + stream().emit("snapshot", snapshot([{ tabId: id(2) }])); + await act(async () => undefined); + expect(toasts).not.toHaveBeenCalled(); + expect(posted).toEqual([]); }); - it("opens a link to another Environment's tab in the panel on a plain click only", async () => { - function ForeignChat() { - useLazurioEnvironmentBrowserFromChat({ - threadRef: threadA, - activities: [], - live: true, - inlinePanel: true, - }); - return ( - - ); - } - await act(async () => root.render()); - const link = container.querySelector("a"); + it("opens a link to one remote tab in the panel on a plain click, held, and leaves other clicks alone", async () => { + await showChat(); + stream().emit("snapshot", snapshot([])); + const [agentTab, foreignLink, outside] = [...container.querySelectorAll("a")]; let followed: string | null = null; const follow = (event: MouseEvent) => { followed = (event.target as HTMLAnchorElement).href; event.preventDefault(); }; document.addEventListener("click", follow); - const click = (init: MouseEventInit = {}): string | null => { + const click = (link: HTMLAnchorElement | undefined, init: MouseEventInit = {}) => { followed = null; act(() => { link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init })); @@ -755,17 +495,16 @@ describe("the chat and the Environment browser", () => { { metaKey: true }, { shiftKey: true }, { altKey: true }, - { button: 1 }, ]) { - expect([gesture, click(gesture)]).toEqual([gesture, foreignView]); + expect(click(agentTab, gesture)).toBe(viewOf(7)); } - expect(panel().isOpen).toBe(false); - expect(click()).toBeNull(); - expect(panel()).toEqual({ - isOpen: true, - activeSurfaceId: foreignTab.id, - surfaces: [foreignTab], - }); + expect(click(outside)).toBe("https://example.com/"); + expect(click(agentTab)).toBeNull(); + expect(panel()).toEqual({ isOpen: true, activeSurfaceId: tab(7).id, surfaces: [tab(7)] }); + await settled(); + expect(posted).toEqual([{ action: "hold", body: { threadId: "thread-A", tabId: id(7) } }]); + expect(click(foreignLink)).toBeNull(); + expect(panel().activeSurfaceId).toBe(foreignTab.id); } finally { document.removeEventListener("click", follow); } diff --git a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx index 507d09a9e586..2bf1e867da47 100644 --- a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx +++ b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx @@ -1,8 +1,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { ExternalLink, Globe2, LogIn } from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import { create } from "zustand"; +import { ExternalLink, Globe2, LogIn, Users } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { @@ -17,59 +16,46 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { - type RightPanelSurface, - selectActiveRightPanel, - selectThreadRightPanelState, - useRightPanelStore, -} from "../rightPanelStore"; +import { type RightPanelSurface, useRightPanelStore } from "../rightPanelStore"; import { usePrimaryEnvironmentId } from "../state/environments"; -import { agentBrowserSessionName } from "./agentBrowserSession"; -import { - type BrowserUseTracker, - type OpenedTab, - type ThreadActivity, - trackBrowserUse, -} from "./browserUse"; import type { LazurioBrowserView } from "./browserView"; import { - type EnvironmentBrowserTab, - environmentBrowserLink, - environmentBrowserNewTabView, - environmentBrowserTab, - fetchEnvironmentBrowser, foreignEnvironmentBrowserTab, foreignEnvironmentIcon, foreignEnvironmentLabel, isPlainPrimaryClick, readEnvironmentBrowserMessage, } from "./environmentBrowser"; - -/** - * The origin of this Environment's view, from the last answer that named a view: where the links - * to its tabs lead. Page memory only, like the answers themselves. - */ -let viewOrigin: string | null = null; - -const askEnvironment = (threadId: string) => - fetchEnvironmentBrowser(agentBrowserSessionName(threadId), window.location.origin, (url, init) => - window.fetch(url, init), - ).then((view) => { - if (view !== null) viewOrigin = new URL(view.view).origin; - return view; - }); - -/** Another Lazurio Environment's tab at `address`, seen from this page (environmentBrowser.ts). */ -const foreignTabAt = (address: unknown) => - foreignEnvironmentBrowserTab(address, window.location.origin, viewOrigin); - -/** The overlay's words are Czech where the person's browser is, as in the Environment's view. */ -const prefersCzech = () => navigator.language.toLowerCase().startsWith("cs"); +import { + type ClosedTabs, + type EnvironmentBrowserSurface, + enterThreadTabs, + leaveThreadTabs, + letGoOfTabs, + openLinkInPanel, + parseForeignTab, + parseThreadTabsAnswer, + placesOf, + prefersCzech, + receiveForeign, + receiveSnapshot, + rememberTitle, + showForeignTab, + showOwnTab, + syncOf, + tabEnded, + tabIdOfSurface, + tabSurfaceId, + tabTooltip, + type ThreadTab, + THREAD_TABS_ROUTE, + useThreadTabsStore, +} from "./threadTabs"; /** * The thread where the Environment browser can serve it, else null: a thread of this page's own * environment, in the web client. The desktop app keeps its own browser, and a thread of another - * environment has its window in that environment's browser. + * environment has its tabs in that environment's browser. */ function useEnvironmentBrowserThread(threadRef: ScopedThreadRef | null): ScopedThreadRef | null { const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -80,218 +66,132 @@ function useEnvironmentBrowserThread(threadRef: ScopedThreadRef | null): ScopedT : null; } -type TabTitles = Readonly>; -const NO_TITLES: TabTitles = {}; - -/** - * The titles the panel's Environment browser tabs last reported, by thread key and surface id. - * Session only: a frame reports its tab's title again whenever it connects. - */ -const useTabTitles = create<{ readonly byThreadKey: Readonly> }>(() => ({ - byThreadKey: {}, -})); +/** A row of the chat's timeline: a message, or the work the agent did. */ +const CHAT_ROW = "[data-timeline-root]"; -function rememberTitle(threadKey: string, surfaceId: string, title: string | null): void { - useTabTitles.setState((state) => { - const titles = state.byThreadKey[threadKey] ?? NO_TITLES; - if ((titles[surfaceId] ?? null) === title) return state; - const { [surfaceId]: _previous, ...others } = titles; - return { - byThreadKey: { - ...state.byThreadKey, - [threadKey]: title === null ? others : { ...others, [surfaceId]: title }, - }, - }; - }); -} +const parseData = (data: unknown) => { + try { + return JSON.parse(String(data)) as unknown; + } catch { + return null; + } +}; /** - * Lazurio overlay (root decision 0191, plan DEV-6646): whether the right panel's Browser opens - * the Environment browser for this thread, and how. The desktop app keeps its own browser, and - * only threads of this page's own environment have their window in its browser, so anywhere - * else this is never available and the Browser stays upstream's. The Environment is asked again - * whenever the panel opens or the thread changes; until it answers, the Browser stays disabled. - * `titles` are what the thread's Environment browser tabs report, by surface id. + * The person's × in the strip: a tab of this Environment that leaves the panel while the thread's + * last list still has it (or its hold is pending) was closed by the person, whichever way (×, + * middle click, Close others, to the right, all), so the thread lets go of it. A tab the list lost + * or that ended in its view leaves without a word. One store change after another in the same task + * (upstream's Close all closes one surface at a time) is one batch and one toast. */ -export function useLazurioEnvironmentBrowser( - threadRef: ScopedThreadRef | null, - panelOpen: boolean, -): { readonly available: boolean; readonly open: () => void; readonly titles: TabTitles } { - const threadId = useEnvironmentBrowserThread(threadRef)?.threadId ?? null; - const [answer, setAnswer] = useState<{ - readonly threadId: string; - readonly available: boolean; - } | null>(null); - useEffect(() => { - if (threadId === null || !panelOpen) return; - let current = true; - void askEnvironment(threadId).then((view) => { - if (current) setAnswer({ threadId, available: view !== null }); +function watchClosedTabs(threadRef: ScopedThreadRef): () => void { + const key = scopedThreadKey(threadRef); + let batch: { + original: readonly RightPanelSurface[]; + closed: EnvironmentBrowserSurface[]; + activeBefore: string | null; + openBefore: boolean; + } | null = null; + return useRightPanelStore.subscribe((state, previous) => { + const before = previous.byThreadKey[key]; + const after = state.byThreadKey[key]; + if (before === undefined || before === after) return; + const sync = syncOf(threadRef); + const snapshot = useThreadTabsStore.getState().snapshots[key]; + const listed = new Set(snapshot?.tabs.map((tab) => tab.tabId) ?? []); + const remaining = new Set((after?.surfaces ?? []).map((surface) => surface.id)); + const closed = before.surfaces.filter((surface): surface is EnvironmentBrowserSurface => { + if (remaining.has(surface.id)) return false; + const tab = tabIdOfSurface(surface); + if (tab === null || sync.ended.has(tab) || sync.released.has(tab)) return false; + return listed.has(tab) || sync.pending.has(tab); }); - return () => { - current = false; - }; - }, [panelOpen, threadId]); - // Upstream's "+ Browser" adds a browser tab. Here the first is the thread's own tab, and once - // that is in the panel each one is a new remote tab of the view (root decision 0191 point 12). - const open = useCallback(() => { - if (threadRef === null) return; - const store = useRightPanelStore.getState(); - const ownTabShown = selectThreadRightPanelState(store.byThreadKey, threadRef).surfaces.some( - (surface) => surface.id === "environment-browser", - ); - if (ownTabShown && viewOrigin !== null) { - store.openEnvironmentBrowserNewTab(threadRef, `${viewOrigin}/`); - } else { - store.open(threadRef, "environment-browser"); + if (closed.length === 0) return; + if (batch === null) { + const started = { + original: before.surfaces, + closed: [] as EnvironmentBrowserSurface[], + activeBefore: before.activeSurfaceId, + openBefore: before.isOpen, + }; + batch = started; + queueMicrotask(() => { + batch = null; + const tabs: ClosedTabs = { + surfaces: started.closed.map((surface) => ({ + surface, + index: started.original.findIndex((entry) => entry.id === surface.id), + })), + activeBefore: started.activeBefore, + openBefore: started.openBefore, + }; + letGoOfTabs(threadRef, tabs); + }); } - }, [threadRef]); - const threadKey = threadRef === null ? null : scopedThreadKey(threadRef); - const titles = useTabTitles((state) => - threadKey === null ? NO_TITLES : (state.byThreadKey[threadKey] ?? NO_TITLES), - ); - return { - available: threadId !== null && answer?.threadId === threadId && answer.available, - open, - titles, - }; + batch.closed.push(...closed); + }); } -/** The thread's own tab, the surface a person picks as Browser. */ -const OWN_TAB = { id: "environment-browser", kind: "environment-browser" } as const; -/** A row of the chat's timeline: a message, or the work the agent did. */ -const CHAT_ROW = "[data-timeline-root]"; - -const showsEnvironmentBrowser = (threadRef: ScopedThreadRef) => - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === - "environment-browser"; - /** - * Lazurio/t3code#48: the tab a completed preview_open answered with joins the panel, in front - * when preview_open showed it to the person (`front`), else behind the surface in view. Another - * Environment's view is that Environment's tab. Any other tab is one of this Environment's view, - * which the Environment is asked for; the thread's own window is the thread's own tab. Bringing a - * tab to the front is automatic: a choice the person made since the call completed wins. + * Lazurio overlay (plan DEV-6646, task 501): ChatView's one hook. While a thread of this page's + * own environment is in view, its strip follows the server's list of the thread's tabs (an event + * stream, threadTabs.ts), and a new reveal request opens the inline panel on its tab (never where + * the panel is a sheet over the chat, `inlinePanel` false). A tab the person closes in the strip is + * let go of with Vrátit; a plain click on a link in the chat to one remote tab, of this + * Environment's view or of another Lazurio Environment's, opens it in the panel instead of a new + * browser tab, and the thread holds a tab of this Environment's. */ -function showOpenedTab( - threadRef: ScopedThreadRef, - opened: OpenedTab, - front: boolean, - inView: { readonly current: string | null }, +export function useLazurioThreadTabs( + input: ScopedThreadRef | null, + options: { readonly inlinePanel: boolean }, ): void { - const revision = useRightPanelStore.getState().getUserActionRevision(threadRef); - const show = (tab: EnvironmentBrowserTab) => { - const store = useRightPanelStore.getState(); - if (!front) store.openEnvironmentBrowserTab(threadRef, tab.view, true); - else - store.openProactive( - threadRef, - { id: tab.id, kind: "environment-browser", view: tab.view }, - revision, - ); - }; - const foreign = foreignTabAt(opened.view); - if (foreign !== null) { - if (foreign.environment.targetId === opened.tabId) show(foreign); - return; - } - const threadKey = scopedThreadKey(threadRef); - void askEnvironment(threadRef.threadId).then((own) => { - if (own === null || viewOrigin === null || inView.current !== threadKey) return; - const tab = environmentBrowserTab(`${viewOrigin}/t/${opened.tabId}`, window.location.origin); - if (tab === null) return; - if (environmentBrowserTab(own.view)?.id !== tab.id) show(tab); - else if (front) useRightPanelStore.getState().openProactive(threadRef, OWN_TAB, revision); - }); -} + const threadRef = useEnvironmentBrowserThread(input); + const { inlinePanel } = options; -/** - * Lazurio overlay (root decision 0191, plan DEV-6646): the chat brings the Environment browser - * into the right panel. When the agent of the thread in view starts to use the browser - * (browserUse.ts) and the Environment offers it, the panel opens on the thread's own tab, the - * surface a person picks by hand, unless it shows the Environment browser already. Only activities - * that arrive while the thread is in view and `live` count, never its history. A person who closes - * the panel or picks another surface sees it open again at the agent's next browser call, so the - * person sees where the agent works; a choice the person makes while the Environment is asked wins - * over the opening. Where the panel is a sheet over the chat (`inlinePanel` false), it does not - * open by itself: it would cover the chat and take the composer's focus. The tab a completed - * preview_open answered with joins the panel as well (showOpenedTab), and a call that opens - * another Environment's view leaves this Environment's browser alone. A plain click on a link in - * the chat to one remote tab of this Environment's view, or of another Lazurio Environment's, - * opens that tab in the panel instead of a new browser tab. - */ -export function useLazurioEnvironmentBrowserFromChat(input: { - readonly threadRef: ScopedThreadRef | null; - readonly activities: ReadonlyArray; - /** False while the thread's history loads or catches up. */ - readonly live: boolean; - /** False where the right panel is a sheet over the chat. */ - readonly inlinePanel: boolean; -}): void { - const { activities, live, inlinePanel } = input; - const threadRef = useEnvironmentBrowserThread(input.threadRef); - const tracker = useRef(null); - // The key of the thread in view, and of the thread whose Environment is being asked. - const inView = useRef(null); - const asking = useRef(null); + // Before the stream's effect, so a thread that comes into view has its panel's kind already. + useEffect(() => { + if (threadRef !== null) syncOf(threadRef).inlinePanel = inlinePanel; + }, [threadRef, inlinePanel]); useEffect(() => { - if (threadRef === null) { - tracker.current = null; - return; - } - const threadKey = scopedThreadKey(threadRef); - const look = trackBrowserUse( - tracker.current, - threadKey, - activities, - live, - (url) => foreignTabAt(url) !== null, + if (threadRef === null) return; + enterThreadTabs(threadRef); + const events = new EventSource( + `${THREAD_TABS_ROUTE}/events?threadId=${encodeURIComponent(threadRef.threadId)}`, ); - tracker.current = look.tracker; - for (const opened of look.opened) { - showOpenedTab(threadRef, opened, opened.visible && inlinePanel, inView); - } - if (!look.used || !inlinePanel || asking.current === threadKey) return; - if (showsEnvironmentBrowser(threadRef)) return; - const revision = useRightPanelStore.getState().getUserActionRevision(threadRef); - asking.current = threadKey; - void askEnvironment(threadRef.threadId).then((view) => { - if (asking.current === threadKey) asking.current = null; - if (view === null || inView.current !== threadKey || showsEnvironmentBrowser(threadRef)) { - return; - } - useRightPanelStore.getState().openProactive(threadRef, OWN_TAB, revision); + events.addEventListener("snapshot", (event) => { + const answer = parseThreadTabsAnswer(parseData(event.data)); + if (answer !== null) receiveSnapshot(threadRef, answer); }); - }, [activities, inlinePanel, live, threadRef]); + events.addEventListener("foreign", (event) => { + const tab = parseForeignTab(parseData(event.data)); + if (tab !== null) receiveForeign(threadRef, tab); + }); + const stopWatching = watchClosedTabs(threadRef); + return () => { + events.close(); + stopWatching(); + leaveThreadTabs(threadRef); + }; + }, [threadRef]); useEffect(() => { if (threadRef === null) return; - inView.current = scopedThreadKey(threadRef); const openInPanel = (event: MouseEvent) => { if (!isPlainPrimaryClick(event) || !(event.target instanceof Element)) return; const link = event.target.closest("a[href]"); if (!(link instanceof HTMLAnchorElement) || link.closest(CHAT_ROW) === null) return; - const tab = - environmentBrowserLink(link.href, window.location.origin, viewOrigin) ?? - foreignTabAt(link.href); - if (tab === null) return; - // The panel takes this click: no new browser tab, and no link handling of the chat's own. + if (!openLinkInPanel(threadRef, link.href)) return; + // The panel took this click: no new browser tab, and no link handling of the chat's own. event.preventDefault(); event.stopPropagation(); - useRightPanelStore.getState().openEnvironmentBrowserTab(threadRef, tab.view); }; // Capturing on the document runs before the chat's own handlers of the link. document.addEventListener("click", openInPanel, true); - return () => { - inView.current = null; - document.removeEventListener("click", openInPanel, true); - }; + return () => document.removeEventListener("click", openInPanel, true); }, [threadRef]); } -type EnvironmentBrowserSurface = Extract; - /** * How long another Environment's view has to tell its tab: after its page loaded, and in all. A * gateway that sends a frame to its sign-in, which refuses to render in a frame, loads an error @@ -324,14 +224,12 @@ const isEnvironmentBrowserSurface = ( /** * The right panel's content on the web: the surface in view (`children`) and every Environment - * browser tab of the thread, each in a frame of its own that stays mounted while the panel - * shows the thread. A frame that unmounts drops its tab's viewer, and the Environment closes a - * tab a page opened 30 s after its last viewer leaves (LazurioPlatform F39 point 9), so only - * closing the panel, closing the tab or leaving the thread may end it. The frames share one - * grid cell with the surface in view, and only the active tab's frame is visible. A hidden - * frame keeps its size (visibility, not display): the view sizes its remote window to its own - * page area, so a frame without one would shrink the window, the agent's tab included. The - * desktop app has its own browser and keeps upstream's panel. + * browser tab of the thread, each in a frame of its own that stays mounted while the panel shows + * the thread, for instant switching. The frames share one grid cell with the surface in view, and + * only the active tab's frame is visible. A hidden frame keeps its size (visibility, not display): + * the view sizes its remote window to its own page area, so a frame without one would shrink the + * window, the agent's tab included. The desktop app has its own browser and keeps upstream's + * panel. */ export function LazurioRightPanelSurfaces(props: { readonly threadRef: ScopedThreadRef; @@ -368,18 +266,96 @@ export function LazurioRightPanelSurfaces(props: { } /** - * An Environment browser tab of the right panel: the view of one remote tab in a frame. The - * thread's own tab is asked of the Environment each time its frame mounts and again on Reload; - * a tab that a page opened frames its own view, and Reload loads it again. A new tab the person - * adds frames the view's bare origin, where the view opens a remote tab, and keeps that tab once - * the view tells it, without loading the frame again. The frame tells the panel its tab's title, - * and a tab its page opens becomes a new tab of the panel, as in the person's own browser (root - * decision 0191 point 12): in front when the page is the one in view, and behind it when the - * page is in a hidden tab, so that a tab an agent opens there does not take the person's place. The gateway's sign-in page cannot render in a frame once the sign-in - * has expired, so the view also opens in a new tab. Another Lazurio Environment's tab is marked - * as that Environment's: a coloured border, and its Organization's picture and name in the head. - * Where its view does not come up (no session at that Environment's gateway, whose sign-in cannot - * render in a frame), the panel asks the person to sign in there. + * RightPanelTabs's one hook: the strip's tooltip of an Environment browser tab, which names the + * other places that hold it and an agent at work. It follows the reported titles and the server's + * facts, so the strip's titles and tooltips are current. + */ +export function useRightPanel() { + const snapshot = useThreadTabsStore((state) => + state.inView === null ? undefined : state.snapshots[scopedThreadKey(state.inView)], + ); + useThreadTabsStore((state) => state.titles); + const czech = prefersCzech(); + return { + tooltip: (surface: RightPanelSurface, title: string) => + surface.kind === "environment-browser" + ? tabTooltip( + title, + snapshot?.tabs.find((tab) => tabSurfaceId(tab.tabId) === surface.id), + czech, + ) + : title, + }; +} + +/** The strip's title of an Environment browser tab: what its frame reported. */ +export function surfaceTitle(surface: EnvironmentBrowserSurface): string { + return useThreadTabsStore.getState().titles[surface.id] ?? "Browser"; +} + +const tabOfInView = + (surfaceId: string) => (state: ReturnType) => { + const snapshot = + state.inView === null ? undefined : state.snapshots[scopedThreadKey(state.inView)]; + return snapshot?.tabs.find((tab) => tabSurfaceId(tab.tabId) === surfaceId); + }; + +/** The strip's icon of an Environment browser tab, marked while an agent works in it or another + * place holds it too. */ +export function SurfaceIcon({ surface }: { readonly surface: EnvironmentBrowserSurface }) { + const tab: ThreadTab | undefined = useThreadTabsStore(tabOfInView(surface.id)); + const shared = tab !== undefined && tab.alsoHeldBy.length > 0; + return ( + + + {tab?.agent || shared ? ( + + ) : null} + + ); +} + +/** A shared tab, said discreetly under the view's bar (wireframe HostPanel `SharedLine`). */ +function SharedLine(props: { readonly threadRef: ScopedThreadRef; readonly tabId: string }) { + const tab = useThreadTabsStore((state) => + state.snapshots[scopedThreadKey(props.threadRef)]?.tabs.find( + (entry) => entry.tabId === props.tabId, + ), + ); + if (tab === undefined || tab.alsoHeldBy.length === 0) return null; + const czech = prefersCzech(); + return ( +
+ + + + {czech ? "Sdílená záložka" : "Shared tab"} + {" "} + · {czech ? "je i" : "also"} {placesOf(tab.alsoHeldBy, czech)} + +
+ ); +} + +/** + * An Environment browser tab of the right panel: the view of one remote tab in a frame. A tab of + * this Environment frames `/t/`, the server's view origin, so no address of it is + * stored. The frame tells the panel its tab's title; a tab its page opens becomes a new tab of the + * panel, in front when the page is the one in view and behind it when the page is hidden, and the + * thread holds it; a tab that ended leaves the panel (root decision 0191 point 12, F47 point 6). + * The gateway's sign-in page cannot render in a frame once the sign-in has expired, so the view + * also opens in a new tab. Another Lazurio Environment's tab is marked as that Environment's: a + * coloured border, and its Organization's picture and name in the head. Where its view does not + * come up (no session at that Environment's gateway), the panel asks the person to sign in there. */ function LazurioEnvironmentBrowser(props: { readonly threadRef: ScopedThreadRef; @@ -387,44 +363,33 @@ function LazurioEnvironmentBrowser(props: { readonly shown: boolean; }) { const { threadRef, surface, shown } = props; - const threadId = threadRef.threadId; - const tabView = "view" in surface ? surface.view : null; - const foreign = tabView === null ? null : foreignTabAt(tabView); - const [request, setRequest] = useState(0); - const [answer, setAnswer] = useState<{ - readonly threadId: string; - readonly request: number; - readonly view: string | null; - } | null>(null); - useEffect(() => { - if (tabView !== null) return; - let current = true; - void askEnvironment(threadId).then((view) => { - if (current) setAnswer({ threadId, request, view: view?.view ?? null }); - }); - return () => { - current = false; - }; - }, [tabView, threadId, request]); - // The frame's address; undefined while the Environment is being asked. - const newTab = tabView !== null && environmentBrowserNewTabView(tabView) !== null; + const snapshot = useThreadTabsStore((state) => state.snapshots[scopedThreadKey(threadRef)]); + const tabId = tabIdOfSurface(surface); + const foreign = + surface.view === undefined + ? null + : foreignEnvironmentBrowserTab( + surface.view, + window.location.origin, + snapshot?.viewOrigin ?? null, + ); + // The frame's address; undefined until the server's first answer, null without a browser. const view = - tabView !== null - ? (environmentBrowserTab(tabView, window.location.origin)?.view ?? - environmentBrowserNewTabView(tabView, window.location.origin)) - : answer?.threadId === threadId && answer.request === request - ? answer.view - : undefined; - // The address this frame mounted with. A new tab that settles on its remote tab keeps its frame, - // which the view moved there itself; Reload loads the settled view. - const [mountedView] = useState(view); - const frameView = request === 0 && mountedView ? mountedView : view; + foreign !== null + ? foreign.view + : tabId !== null && snapshot !== undefined && snapshot !== null + ? `${snapshot.viewOrigin}/t/${tabId}` + : snapshot === null + ? null + : undefined; + const [request, setRequest] = useState(0); // The requests whose frame told its tab (its view came up), fired load, or ran out of time. const [cameUp, setCameUp] = useState(null); const [loaded, setLoaded] = useState(null); const [late, setLate] = useState(null); const frame = useRef(null); + const isForeign = foreign !== null; useEffect(() => { if (!view) return; const listen = (event: MessageEvent) => { @@ -435,24 +400,20 @@ function LazurioEnvironmentBrowser(props: { ); if (message?.type === "info") { setCameUp(request); - rememberTitle(scopedThreadKey(threadRef), surface.id, message.title); - if (newTab && message.tab !== null) { - useRightPanelStore - .getState() - .settleEnvironmentBrowserTab(threadRef, surface.id, message.tab.view); - } + rememberTitle(surface.id, message.title); } else if (message?.type === "new-tab") { - useRightPanelStore - .getState() - .openEnvironmentBrowserTab(threadRef, message.tab.view, !shown); + const target = /\/t\/([0-9A-Fa-f]{32})$/.exec(message.tab.view)?.[1]; + if (isForeign) showForeignTab(threadRef, message.tab.view, shown); + else if (target !== undefined) showOwnTab(threadRef, target, shown); + } else if (message?.type === "closed") { + tabEnded(threadRef, surface.id); } }; window.addEventListener("message", listen); return () => window.removeEventListener("message", listen); - }, [view, threadRef, surface.id, shown, request, newTab]); + }, [view, threadRef, surface.id, shown, request, isForeign, frame]); // Another Environment's view tells its tab soon after its page loads, or it never came up. - const isForeign = foreign !== null; useEffect(() => { if (!isForeign || !view || cameUp === request) return; const wait = loaded === request ? FOREIGN_VIEW_AFTER_LOAD_MS : FOREIGN_VIEW_MS; @@ -494,7 +455,7 @@ function LazurioEnvironmentBrowser(props: { variant="ghost" size="icon-xs" aria-label="Reload" - disabled={view === undefined} + disabled={!view} onClick={() => setRequest((value) => value + 1)} /> } @@ -514,12 +475,13 @@ function LazurioEnvironmentBrowser(props: { ) : null}
+ {tabId !== null ? : null} {view ? (