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
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2060,7 +2060,10 @@ export default function ChatView(props: ChatViewProps) {
const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
const rightPanelOpen = rightPanelState.isOpen;
// Lazurio overlay: the thread's Environment browser tabs in the panel (apps/web/src/lazurio).
useLazurioThreadTabs(activeThreadRef, { inlinePanel: !shouldUseRightPanelSheet });
useLazurioThreadTabs(activeThreadRef, {
inlinePanel: !shouldUseRightPanelSheet,
draft: !isServerThread,
});
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
usePanelAnimationSettings();
const activeTerminalDrawerPresence = usePanelPresence(
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ function RightPanelEmptyState(props: {
agentsAvailable: boolean;
deviceAvailable: boolean;
liveAgentCount: number;
/** Lazurio overlay: why the Environment browser is not here yet, instead of upstream's line. */
browserNote: string | null;
}) {
// -1 means no highlight: it only appears on hover or arrow use.
const [highlight, setHighlight] = useState(-1);
Expand All @@ -361,7 +363,7 @@ function RightPanelEmptyState(props: {
icon: Globe2,
shortcut: "B",
available: props.browserAvailable,
disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,
disabledReason: props.browserNote ?? SURFACE_UNAVAILABLE_HINTS.browser,
onClick: props.onAddBrowser,
badgeCount: 0,
},
Expand Down Expand Up @@ -616,6 +618,9 @@ function RightPanelEmptyState(props: {
),
)}
</div>
{props.browserNote === null ? null : (
<p className="mt-3 text-center text-muted-foreground text-xs">{props.browserNote}</p>
)}
</div>
</div>
);
Expand Down Expand Up @@ -1512,6 +1517,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
agentsAvailable={props.agentsAvailable}
deviceAvailable={props.deviceAvailable}
liveAgentCount={props.liveAgentCount}
browserNote={lazurio.browserNote}
/>
) : (
props.children
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/lazurio/LazurioEnvironmentBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,15 @@ describe("another Environment's browser in the panel", () => {
});

describe("the chat's hook", () => {
function Chat(props: { readonly threadRef: ScopedThreadRef; readonly inlinePanel?: boolean }) {
useLazurioThreadTabs(props.threadRef, { inlinePanel: props.inlinePanel ?? true });
function Chat(props: {
readonly threadRef: ScopedThreadRef;
readonly inlinePanel?: boolean;
readonly draft?: boolean;
}) {
useLazurioThreadTabs(props.threadRef, {
inlinePanel: props.inlinePanel ?? true,
draft: props.draft ?? false,
});
return (
<div data-timeline-root="true">
<a href={viewOf(7)}>The agent's tab</a>
Expand Down Expand Up @@ -383,6 +390,25 @@ describe("the chat's hook", () => {
expect(useThreadTabsStore.getState().inView).toBeNull();
});

it("opens no stream for a draft, and follows the thread once its first message makes it one", async () => {
// The draft keeps its ref when it becomes a thread (ChatView's promotion is data-driven).
await act(async () => root.render(<Chat threadRef={threadA} draft />));
expect(FakeEventSource.instances).toHaveLength(0);
expect(useThreadTabsStore.getState().draftInView).toEqual(threadA);
expect(useThreadTabsStore.getState().inView).toBeNull();
await act(async () => root.render(<Chat threadRef={threadA} draft={false} />));
expect(stream().url).toBe("/api/lazurio/thread-tabs/events?threadId=thread-A");
expect(useThreadTabsStore.getState().draftInView).toBeNull();
expect(useThreadTabsStore.getState().inView).toEqual(threadA);
stream().emit("snapshot", snapshot([{ tabId: id(1) }]));
expect(panel().surfaces).toEqual([tab(1)]);
// Another draft in view takes the line; leaving it clears it.
await act(async () => root.render(<Chat threadRef={threadB} draft />));
expect(useThreadTabsStore.getState().draftInView).toEqual(threadB);
await act(async () => root.render(<p>Elsewhere</p>));
expect(useThreadTabsStore.getState().draftInView).toBeNull();
});

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);
Expand Down
34 changes: 29 additions & 5 deletions apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ import {
isPlainPrimaryClick,
readEnvironmentBrowserMessage,
} from "./environmentBrowser";
import { NewTabSurface, type SurfaceAction, useNewTab } from "./LazurioNewTab";
import { NewTabSurface, type SurfaceAction, useNewTab, useNewTabDefined } from "./LazurioNewTab";
import {
type ClosedTabs,
draftBrowserNote,
type EnvironmentBrowserSurface,
enterDraft,
enterThreadTabs,
isNewTabSurface,
leaveDraft,
leaveThreadTabs,
letGoOfTabs,
openLinkInPanel,
Expand Down Expand Up @@ -145,21 +148,32 @@ function watchClosedTabs(threadRef: ScopedThreadRef): () => void {
* 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.
*
* A draft is no thread of the server yet: its stream would answer 404, which an EventSource never
* retries, and the draft keeps its ref when its first message makes it a thread. So a draft opens
* no stream, its empty panel says the browser comes with the first message, and the stream opens
* when ChatView stops calling it a draft (Lazurio/t3code#53).
*/
export function useLazurioThreadTabs(
input: ScopedThreadRef | null,
options: { readonly inlinePanel: boolean },
options: { readonly inlinePanel: boolean; readonly draft: boolean },
): void {
const threadRef = useEnvironmentBrowserThread(input);
const { inlinePanel } = options;
const { inlinePanel, draft } = options;

// 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) return;
if (threadRef === null || !draft) return;
enterDraft(threadRef);
return () => leaveDraft(threadRef);
}, [threadRef, draft]);

useEffect(() => {
if (threadRef === null || draft) return;
enterThreadTabs(threadRef);
const events = new EventSource(
`${THREAD_TABS_ROUTE}/events?threadId=${encodeURIComponent(threadRef.threadId)}`,
Expand All @@ -178,7 +192,7 @@ export function useLazurioThreadTabs(
stopWatching();
leaveThreadTabs(threadRef);
};
}, [threadRef]);
}, [threadRef, draft]);

useEffect(() => {
if (threadRef === null) return;
Expand Down Expand Up @@ -292,9 +306,19 @@ export function useRightPanel(actions: ReadonlyArray<SurfaceAction>) {
state.inView === null ? undefined : state.snapshots[scopedThreadKey(state.inView)],
);
useThreadTabsStore((state) => state.titles);
// A draft on an Environment whose shell offers Nová karta, unless the server already said that
// this Environment has no browser for another thread.
const draftNote = useThreadTabsStore(
(state) =>
state.draftInView !== null &&
!Object.values(state.snapshots).some((answer) => answer === null),
);
const defined = useNewTabDefined();
const czech = prefersCzech();
return {
newTab,
/** The empty panel's line about the Environment browser, or null for upstream's. */
browserNote: draftNote && defined ? draftBrowserNote(czech) : null,
tooltip: (surface: RightPanelSurface, title: string) =>
surface.kind === "environment-browser" && !isNewTabSurface(surface)
? tabTooltip(
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/lazurio/LazurioNewTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const snapshot: ThreadTabsSnapshot = {
viewOrigin,
tabs: [],
};
/** The draft's line in English, jsdom's language. */
const DRAFT_LINE = "The Environment browser comes with the first message.";
const panel = () => selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadA);
const setPanel = (state: ThreadRightPanelState) =>
useRightPanelStore.setState({ byThreadKey: { [scopedThreadKey(threadA)]: state } });
Expand Down Expand Up @@ -245,6 +247,14 @@ describe("without the shell's element", () => {
expect(container.querySelector('[aria-label="Add panel surface"]')).not.toBeNull();
expect(container.querySelector('[aria-label="New tab"]')).toBeNull();
});

it("keeps upstream's empty panel on a draft, without the Environment browser's line", async () => {
useThreadTabsStore.setState({ draftInView: threadA });
setPanel({ isOpen: true, activeSurfaceId: null, surfaces: [] });
await renderStrip();
expect(container.textContent).toContain("Open a surface");
expect(container.textContent).not.toContain(DRAFT_LINE);
});
});

describe("with the shell's element", () => {
Expand All @@ -262,6 +272,25 @@ describe("with the shell's element", () => {
expect(container.textContent).toContain("Open a surface");
});

it("says on a draft's empty panel that the Environment browser comes with its first message", async () => {
useThreadTabsStore.setState({ draftInView: threadA });
setPanel({ isOpen: true, activeSurfaceId: null, surfaces: [] });
await renderStrip();
expect(container.textContent).toContain("Open a surface");
expect(container.textContent).toContain(DRAFT_LINE);
expect(container.querySelector(NEW_TAB_ELEMENT)).toBeNull();
// Where the server said of another thread that this Environment has no browser, upstream's
// line stays.
useThreadTabsStore.setState({
snapshots: {
[scopedThreadKey(scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")))]:
null,
},
});
await renderStrip();
expect(container.textContent).not.toContain(DRAFT_LINE);
});

it("shows Nová karta as the empty panel, with the thread's strip and upstream's tools", async () => {
inView();
setPanel({ isOpen: true, activeSurfaceId: null, surfaces: [] });
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lazurio/LazurioNewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ if (typeof customElements !== "undefined") {
.then(() => useNewTabStore.setState({ defined: true }));
}

/** Whether this page's shell defines Nová karta: the page is a Lazurio Environment's. */
export const useNewTabDefined = () => useNewTabStore((state) => state.defined);

/** Upstream's actions as RightPanelTabs last rendered them: each runs in Nová karta's place. */
let publishedActions: ReadonlyArray<SurfaceAction> = [];

Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/lazurio/threadTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,16 @@ interface ThreadTabsState {
readonly titles: Readonly<Record<string, string>>;
/** The thread the chat shows, whose panel may show Nová karta. */
readonly inView: ScopedThreadRef | null;
/** The draft the chat shows: no thread of the server yet, so it has no tabs until its first
* message (Lazurio/t3code#53). */
readonly draftInView: ScopedThreadRef | null;
}

export const useThreadTabsStore = create<ThreadTabsState>(() => ({
snapshots: {},
titles: {},
inView: null,
draftInView: null,
}));

export function rememberTitle(surfaceId: string, title: string | null): void {
Expand Down Expand Up @@ -382,7 +386,7 @@ export function syncOf(threadRef: ScopedThreadRef): ThreadTabsSync {
/** Tests start from nothing. */
export function resetThreadTabs(): void {
syncs.clear();
useThreadTabsStore.setState({ snapshots: {}, titles: {}, inView: null });
useThreadTabsStore.setState({ snapshots: {}, titles: {}, inView: null, draftInView: null });
}

export type PostOutcome =
Expand Down Expand Up @@ -532,6 +536,25 @@ export function leaveThreadTabs(threadRef: ScopedThreadRef): void {
}
}

/** A draft comes into view: the panel says that the Environment browser comes with the first
* message, instead of upstream's desktop-only line. */
export function enterDraft(threadRef: ScopedThreadRef): void {
useThreadTabsStore.setState({ draftInView: threadRef });
}

export function leaveDraft(threadRef: ScopedThreadRef): void {
const draft = useThreadTabsStore.getState().draftInView;
if (draft !== null && scopedThreadKey(draft) === scopedThreadKey(threadRef)) {
useThreadTabsStore.setState({ draftInView: null });
}
}

/** The empty panel's line on a draft (Lazurio/t3code#53, Matěj chose the line on 2026-10-12). */
export const draftBrowserNote = (czech: boolean) =>
czech
? "Prohlížeč Environmentu bude k dispozici po první zprávě."
: "The Environment browser comes with the first message.";

const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];

/** A new intent for `tab`, a hold or a release: from now on every older one's retry and answer do
Expand Down
18 changes: 15 additions & 3 deletions docs/operations/lazurio-fork-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,16 @@ T3 žádný prohlížeč nemá, a tak overlay dělá tohle:
když je Nová karta vidět a fokus je v dokumentu T3; v rámu je předává stránka.
Bez elementu (starší Platforma) zůstává upstream nabídka i prázdný stav a
Browser je ve webu nedostupný.
- **Rozepsané vlákno** (Lazurio/t3code#53, varianta, kterou Matěj zvolil
2026-10-12). Koncept ještě není vlákno serveru: proud záložek by odpověděl
404, který EventSource znovu nezkouší, a koncept si po první zprávě nechá
stejný ref vlákna. Proto koncept proud neotevře a jeho prázdný panel zůstane
upstream, jen místo „Only available in the desktop app.“ řekne pod seznamem
i jako důvod u řádku Browser „Prohlížeč Environmentu bude k dispozici po
první zprávě.“ Jakmile ho ChatView přestane označovat za koncept, proud se
otevře a panel ukáže Novou kartu. Kde server o jiném vlákně řekl, že
Environment prohlížeč nemá, zůstane upstream řádek. Menu „+“ při jiných
otevřených površích konceptu nechává u Browser upstream důvod.
- **× pustí záložku jen pro toto vlákno.** ×, prostřední tlačítko i Zavřít,
Zavřít ostatní, Zavřít vpravo a Zavřít vše pošlou release. Jeden toast (i pro
Zavřít vše) řekne podle snímku, co se stane: pracuje-li v záložce agent,
Expand Down Expand Up @@ -478,8 +488,9 @@ T3 žádný prohlížeč nemá, a tak overlay dělá tohle:
Upstream soubory overlay mění jen ve švech: `rightPanelStore.ts` (druh
povrchu, jeden člen povrchu, Nová karta jako jeho singleton, druh v
`openProactive` a jedna obecná akce `lazurio`), `RightPanelTabs.tsx` (jedno
volání háčku, „+“ a prázdný stav za podmínkou Nové karty, název, ikona a tooltip
záložky), `ChatView.tsx` (jedno volání háčku a obal, který drží rámy vedle
volání háčku, „+“ a prázdný stav za podmínkou Nové karty, řádek prázdného stavu
na konceptu, název, ikona a tooltip záložky), `ChatView.tsx` (jedno volání háčku
s příznakem konceptu a obal, který drží rámy vedle
povrchu v popředí), `ProviderService.ts` (jedno volání), `server.ts` (jedna
vrstva overlaye vedle MCP serveru), `PreviewAutomationBroker.ts` (preferovaný
host a jeho přednost i před dřívějším přiřazením desktopu, jeho 60 s pro `open`
Expand Down Expand Up @@ -680,7 +691,8 @@ t3code.service && systemctl --user start t3code.service` (#25).
záložkou vlákna; nástroj otevře povrch T3; druhé zařízení ukáže stejné
záložky; × s Vrátit a sdílená záložka proti Launchpadu; `preview_open` i
`lazurio browser window` agenta ukážou své okno; archivace ukončí jen záložky
toho vlákna, po 8 s; SSE projde bránou Environmentu.
toho vlákna, po 8 s; SSE projde bránou Environmentu; nové vlákno před první
zprávou ukáže větu o prohlížeči a po ní Novou kartu bez obnovení stránky.

4. **Stable a tlačítko Update.** Stable vydej ze stejného commitu až po
zeleném canary a výslovném souhlasu Admina. Canary Mašiny vrať z preview
Expand Down
10 changes: 9 additions & 1 deletion scripts/lazurio-release-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,24 @@ NodeTest.test(
/case "environment-browser":\n\s+return <Lazurio\.SurfaceIcon surface=\{surface\} \/>;/,
);
NodeAssert.match(tabs, /\n {2}const lazurio = Lazurio\.useRightPanel\(addSurfaceActions\);\n/);
// The empty panel's line on a draft: the Browser row's reason and one line under the list.
NodeAssert.match(tabs, /browserNote=\{lazurio\.browserNote\}/);
NodeAssert.match(
tabs,
/disabledReason: props\.browserNote \?\? SURFACE_UNAVAILABLE_HINTS\.browser,/,
);
NodeAssert.equal(tabs.match(/props\.browserNote/g)?.length, 3);
NodeAssert.match(
tabs,
/\) : \(\n\s+lazurio\.tooltip\(surface, title\)\n\s+\)\}\n\s+<\/TooltipPopup>/,
);
NodeAssert.doesNotMatch(tabs, /environmentBrowserTitles|previewBridge \? browserDefaults/);
// ChatView: one hook call, and upstream's surface in view wrapped once beside the frames.
NodeAssert.equal(chatView.match(/useLazurioThreadTabs\(/g)?.length, 1);
// A draft opens no stream until its first message makes it a thread (Lazurio/t3code#53).
NodeAssert.match(
chatView,
/useLazurioThreadTabs\(activeThreadRef, \{ inlinePanel: !shouldUseRightPanelSheet \}\);/,
/useLazurioThreadTabs\(activeThreadRef, \{\n\s+inlinePanel: !shouldUseRightPanelSheet,\n\s+draft: !isServerThread,\n\s+\}\);/,
);
NodeAssert.match(chatView, /const rightPanelActiveContent = activeThreadRef \? \(/);
NodeAssert.match(
Expand Down
Loading