Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/server/src/preview/DesktopBrowserChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export class DesktopBrowserChannel extends Context.Service<
* attach landing in between is never missed. False after `timeout`.
*/
readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>;
/** Desktop tabs as they attach. */
readonly attached: Stream.Stream<DesktopTabKey>;
/** Desktop tabs as they detach. */
readonly detached: Stream.Stream<DesktopTabKey>;
readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
Expand Down Expand Up @@ -82,6 +84,7 @@ const make = Effect.gen(function* () {
return DesktopBrowserChannel.of({
available: false,
awaitAttached: () => Effect.succeed(false),
attached: Stream.empty,
detached: Stream.empty,
isAttached: () => Effect.succeed(false),
endpoint: () => Effect.die("No desktop app is attached to this server."),
Expand Down Expand Up @@ -212,6 +215,10 @@ const make = Effect.gen(function* () {
);
}),
),
attached: Stream.fromPubSub(changes).pipe(
Stream.filter((change) => change.attached),
Stream.map((change) => change.key),
),
detached: Stream.fromPubSub(changes).pipe(
Stream.filter((change) => !change.attached),
Stream.map((change) => change.key),
Expand Down
47 changes: 46 additions & 1 deletion apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ let clipboardBinding: ClipboardBinding | null = null;
let contextFailure: Error | null = null;
/** Server tabs the fake desktop renders, and the endpoints the server connected to. */
let desktopRendersNext = false;
/** Pages the fake desktop takes back; the channel's detached stream emits them. */
/** Pages the fake desktop mounts late or takes back; the channel's streams emit them. */
const desktopDetaches = new NodeEvents.EventEmitter();
const desktopTabs = new Set<string>();
const desktopRenders = (tabId: string) => {
Expand Down Expand Up @@ -198,6 +198,17 @@ const dependencies = Layer.mergeAll(
// Only tabs a test marks render on the desktop; the rest stay headless.
available: true,
awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
attached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
const onAttach = (key: { threadId: string; tabId: string }) =>
Queue.offerUnsafe(queue, key);
desktopDetaches.on("attach", onAttach);
return onAttach;
}),
(onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)),
),
),
detached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
Expand Down Expand Up @@ -1064,6 +1075,40 @@ it.live("drives the desktop's own page for a tab the desktop renders", () =>
).pipe(Effect.provide(layer)),
);

it.live("an agent tab that started headless moves to the desktop's page once it mounts", () =>
Effect.scoped(
Effect.gen(function* () {
const { browser, broker, tabId } = yield* ready;
// No panel showed the agent's tab in time, so it runs headless in its own storage.
expect(desktopConnections).toEqual([]);
const headless = contexts[0]!;
const viewer = yield* browser.attachViewer(viewerInput(tabId, false));
// The person opens the panel, and the desktop mounts its own page for the tab.
while (desktopDetaches.listenerCount("attach") === 0) yield* Effect.yieldNow;
desktopTabs.add(tabId);
desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId });
let end = yield* Queue.take(viewer.output);
while (end._tag !== "reconnect" && end._tag !== "gone")
end = yield* Queue.take(viewer.output);
expect(end._tag).toBe("reconnect");
while (desktopConnections.length === 0) yield* Effect.yieldNow;
expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([
`ws://desktop/${tabId}`,
]);
expect(headless.close).toHaveBeenCalled();
// The agent keeps the same tab and now drives the page the person sees.
const status = yield* broker.invoke<PreviewAutomationStatus>({
scope,
operation: "status",
input: {},
tabId,
});
expect(status).toMatchObject({ tabId });
expect(contexts).toHaveLength(1);
}),
).pipe(Effect.provide(layer)),
);

it.live("a desktop page the desktop takes back reconnects instead of closing", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
24 changes: 21 additions & 3 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import { constVoid } from "effect/Function";
import { constUndefined, constVoid } from "effect/Function";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
Expand Down Expand Up @@ -641,13 +641,15 @@ const make = Effect.gen(function* () {
/** Whether the preview session for a tab still exists. */
const sessionOpen = (tab: ServerTab) => !closedSessions.has(tabKey(tab.threadId, tab.tabId));

const dropTab = (tab: ServerTab, closeSession: boolean) => {
/** `handOff` keeps viewers for a session that moves to another page, such as the desktop's. */
const dropTab = (tab: ServerTab, closeSession: boolean, handOff = false) => {
const key = tabKey(tab.threadId, tab.tabId);
if (tabs.get(key) !== tab) return;
tabs.delete(key);
tab.closing = true;
// A desktop page outlives the connection unless its session closed with it.
const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";
const end =
(tab.desktop || handOff) && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";
for (const viewer of tab.viewers) viewer.push({ _tag: end });
void tab.control.close().catch(constVoid);
// The desktop owns its page; letting go only ends this connection.
Expand Down Expand Up @@ -2142,6 +2144,22 @@ const make = Effect.gen(function* () {
),
Effect.forkScoped,
);
// A tab opened while no desktop panel showed it in time started headless, with
// its own storage. Once the desktop mounts it, move the session onto the
// desktop's page so agents drive what the person sees, with their sign-ins.
yield* desktopChannel.attached.pipe(
Stream.mapEffect(
(key) =>
Effect.promise(async () => {
const id = tabKey(key.threadId, key.tabId);
const tab = tabs.get(id) ?? (await pendingTabs.get(id)?.catch(constUndefined));
if (tab && !tab.desktop) dropTab(tab, false, true);
}).pipe(Effect.andThen(findTab(key.threadId, key.tabId)), Effect.ignore),
{ concurrency: "unbounded" },
),
Stream.runDrain,
Effect.forkScoped,
);
yield* Effect.sync(closeIdleAgentTabs).pipe(
Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)),
Effect.forkScoped,
Expand Down
Loading