Skip to content
Merged
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
33 changes: 20 additions & 13 deletions .github/workflows/lazurio-fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <lazurio-new-tab>, "+" 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)/' |
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/lazurio/environmentBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +180,30 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray<string>
}> = [];
let registered = yield* Deferred.make<void>();
const reported: Array<PreviewAutomationHostFocus> = [];
// 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<string | undefined> = [];
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<PreviewAutomationOperation> = [];
const deliveryWaiters: Array<{
readonly operation: PreviewAutomationOperation;
Expand Down Expand Up @@ -249,13 +274,17 @@ const startHost = (cli: { link: CliAnswer; window?: (args: ReadonlyArray<string>
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.provideService(HostProcessEnvironment, { HOME: home }),
Effect.provideService(EnvironmentBrowser.EnvironmentBrowserConnect, browser.connect),
Effect.provideService(ThreadTabs.ThreadTabs, threadTabs),
);
return {
broker,
browser,
runs,
registrations,
reported,
recorded,
foreignShown,
declarations,
setLink: (answer: CliAnswer) => {
cli.link = answer;
},
Expand Down Expand Up @@ -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}`]),
);
}),
),
);
Expand Down Expand Up @@ -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"]);
}),
),
);
Expand Down
51 changes: 34 additions & 17 deletions apps/server/src/lazurio/environmentBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -278,17 +283,22 @@ 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 {
...statusOf(view.targetId, { url: null, title: null, loading: false }, visible),
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(() =>
Expand All @@ -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) ?? {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading