diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 35a9c0387d52..1434aacc90d5 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -78,29 +78,36 @@ 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. apps/web/src/lazurio/browserView.ts + # A thread's tabs from the server and Nová karta behind "+" (LazurioPlatform F47 step 4, + # task 501): where the Environment's shell defines , "+" and the empty + # panel open Nová karta with upstream's own "+" actions as its tools. The seams are one + # hook call and two conditionals in RightPanelTabs.tsx (listed above). + apps/web/src/lazurio/LazurioNewTab.test.tsx + apps/web/src/lazurio/LazurioNewTab.tsx + # 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/server/src/lazurio/environmentBrowser.test.ts b/apps/server/src/lazurio/environmentBrowser.test.ts index 3b23f8d63aa1..1b0f180a38c5 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,30 @@ 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 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"), + 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: (thread, tab, reveal) => + Effect.sync(() => foreignShown.push({ threadId: thread, ...tab, reveal })), + }); const delivered: Array = []; const deliveryWaiters: Array<{ readonly operation: PreviewAutomationOperation; @@ -249,6 +274,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 +282,9 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray runs, registrations, reported, + recorded, + foreignShown, + declarations, setLink: (answer: CliAnswer) => { cli.link = answer; }, @@ -445,6 +474,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 +647,16 @@ 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"] }]); + // 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 6ab0cea7387f..90e312ee5790 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 @@ -19,8 +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. + * 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 { @@ -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; @@ -278,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 { @@ -288,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(() => @@ -304,6 +314,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 +326,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) => { @@ -496,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)); @@ -683,11 +695,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..185232d156ba --- /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 archiving or deleting a thread lets go of them (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/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/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 new file mode 100644 index 000000000000..1e56776a9706 --- /dev/null +++ b/apps/server/src/lazurio/threadLifecycle.ts @@ -0,0 +1,123 @@ +/** + * 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. + * 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"; +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 * 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 { + readonly id: ThreadId; + 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", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "The threads could not be read."; + } +} + +export class ThreadLifecycle extends Context.Service< + ThreadLifecycle, + { + /** Settles once the projection has caught up with the event log (command readiness). */ + readonly ready: Effect.Effect; + /** 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, + ) => Effect.Effect, ThreadLifecycleReadError>; + /** Every live thread. */ + readonly liveThreads: Effect.Effect, ThreadLifecycleReadError>; + } +>()("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 }))), + 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..4e4e27a13106 --- /dev/null +++ b/apps/server/src/lazurio/threadTabs.test.ts @@ -0,0 +1,755 @@ +// @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; 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 })!; + 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 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, + socketDirectory: sockets, + 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), + commandLine: async (pid) => processes.get(pid) ?? "", + terminate: (pid) => { + terminated.push(pid); + processes.delete(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 && options.ready !== false) await declare(); + const fileOf = (strip: string) => NodePath.join(holders, `${strip}.json`); + return { + runtime, + holders, + listPath, + sockets, + core, + 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, + /** 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 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 () => { + 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", + }); + }); + + 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", () => { + 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; 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 () => { + 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..8f22ac1b980b --- /dev/null +++ b/apps/server/src/lazurio/threadTabs.ts @@ -0,0 +1,916 @@ +// @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). + * 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"; +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 { forkParked } from "../serverActivation.ts"; +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, + stopSessionDaemon, + watchDirectories, +} from "./holders.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; +/** 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>; + /** 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; + /** 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; + /** 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; + /** 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(); + /** 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(); + /** 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); + }; + + /** + * 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); + 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(live.title); + if (retired.has(strip)) throw refusal("thread-not-found"); + 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; + } + + 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(); + 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 tab = tabId.toUpperCase(); + 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), + }); + } + } + + /** 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 { + await seams.ready; + if (stopped) return; + await serialized(async () => { + await sweep(); + 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(); + } + + 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; + 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 (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(); + await holdTab(threadId, tabId.toUpperCase()); + publish(); + return snapshotFor(stripOf(threadId), 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); + }, + 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 }; + 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); + let markReady = () => {}; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + 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), + ready, + 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())); + // 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)), + 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..1eb6e4eb1224 --- /dev/null +++ b/apps/server/src/lazurio/threadTabsRoute.test.ts @@ -0,0 +1,463 @@ +// @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({ + ready: Effect.void, + events: Stream.never, + 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/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 {}; } } 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( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 162ff9c86ab7..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); @@ -5189,6 +5182,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( @@ -10298,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) => { @@ -10308,10 +10307,9 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={ - environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() - } + onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} @@ -10320,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} @@ -10358,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) => { @@ -10368,10 +10365,9 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={ - environmentBrowser.available ? environmentBrowser.open : () => createBrowserSurface() - } + onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} @@ -10380,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 e65ccb502c66..edf86befaa2e 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, @@ -71,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"; @@ -102,14 +115,14 @@ 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; 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 +213,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; @@ -611,7 +625,6 @@ function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, terminalLabelsById: ReadonlyMap, - environmentBrowserTitles: Readonly> | undefined, ): string { switch (surface.kind) { case "diff": @@ -636,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"; @@ -721,7 +734,7 @@ function SurfaceIcon({ case "agents": return ; case "environment-browser": - return ; + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -831,12 +844,64 @@ 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(); - // 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); @@ -944,6 +1009,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddDevice, }, ] as const; + const lazurio = Lazurio.useRightPanel(addSurfaceActions); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1138,142 +1204,146 @@ 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, + ); + 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 - )} - - - )} -
- ); - })} - {props.surfaces.length > 0 ? ( + ) : ( + + { + 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" ? ( + + ) : ( + lazurio.tooltip(surface, title) + )} + + + )} + + ); + })} + + {props.surfaces.length > 0 && lazurio.newTab ? : null} + {props.surfaces.length > 0 && !lazurio.newTab ? (
0 - ) { + if (action.label === "Browser" && action.available) { return (
- {props.activeSurfaceId === null ? ( + {props.activeSurfaceId === null && lazurio.newTab ? ( + + ) : props.activeSurfaceId === null ? ( ({ 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..2a882fe8f84c 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, Plus, Users } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { @@ -17,59 +16,51 @@ 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"; +import { NewTabSurface, type SurfaceAction, useNewTab } from "./LazurioNewTab"; +import { + type ClosedTabs, + type EnvironmentBrowserSurface, + enterThreadTabs, + isNewTabSurface, + 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 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"); +// RightPanelTabs reaches the overlay through this one module: Nová karta is LazurioNewTab.tsx's. +export { NewTab, NewTabButton } from "./LazurioNewTab"; /** * 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 +71,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 +229,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. A Nová karta tab shows only while it is in view. The desktop + * app has its own browser and keeps upstream's panel. */ export function LazurioRightPanelSurfaces(props: { readonly threadRef: ScopedThreadRef; @@ -346,6 +249,16 @@ export function LazurioRightPanelSurfaces(props: {
{props.children}
{props.surfaces.filter(isEnvironmentBrowserSurface).map((surface) => { const shown = surface.id === props.activeSurfaceId; + if (isNewTabSurface(surface)) { + return shown ? ( +
+ +
+ ) : null; + } return ( + {tabId !== null ? : null} {view ? (