From 88b8ff8606657d0116dc98728f6dc4302d242078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 12:20:09 +0200 Subject: [PATCH 1/3] fix(web): a draft thread's panel says the Environment browser comes with its first message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a new thread, before its first message, the right panel showed upstream's empty state with Browser "Only available in the desktop app." (#53). A draft is no thread of the server yet, so its tab stream answered 404, which an EventSource never retries. The draft also keeps its ref when its first message promotes it, so the failed stream stayed closed. Nová karta then did not appear on the new thread until the page was reloaded or the person navigated away and back. ChatView now tells the overlay whether the thread is a draft: - a draft opens no stream, and its empty panel stays upstream's; - under the list, and as the Browser row's reason, the panel says "Prohlížeč Environmentu bude k dispozici po první zprávě." (the variant Matěj chose on 2026-10-12); - once the thread is the server's, the stream opens and Nová karta follows. Where the server already said of another thread that this Environment has no browser, upstream's line stays. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 5 ++- apps/web/src/components/RightPanelTabs.tsx | 8 ++++- .../LazurioEnvironmentBrowser.test.tsx | 30 ++++++++++++++-- .../src/lazurio/LazurioEnvironmentBrowser.tsx | 34 ++++++++++++++++--- apps/web/src/lazurio/LazurioNewTab.test.tsx | 29 ++++++++++++++++ apps/web/src/lazurio/LazurioNewTab.tsx | 3 ++ apps/web/src/lazurio/threadTabs.ts | 25 +++++++++++++- 7 files changed, 124 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 13995f28d4a5..0b6c817941aa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index edf86befaa2e..4924230cc16d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -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); @@ -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, }, @@ -616,6 +618,9 @@ function RightPanelEmptyState(props: { ), )} + {props.browserNote === null ? null : ( +

{props.browserNote}

+ )} ); @@ -1512,6 +1517,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { agentsAvailable={props.agentsAvailable} deviceAvailable={props.deviceAvailable} liveAgentCount={props.liveAgentCount} + browserNote={lazurio.browserNote} /> ) : ( props.children diff --git a/apps/web/src/lazurio/LazurioEnvironmentBrowser.test.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.test.tsx index 4011a9540ae2..25d6723df97e 100644 --- a/apps/web/src/lazurio/LazurioEnvironmentBrowser.test.tsx +++ b/apps/web/src/lazurio/LazurioEnvironmentBrowser.test.tsx @@ -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 (
The agent's tab @@ -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()); + expect(FakeEventSource.instances).toHaveLength(0); + expect(useThreadTabsStore.getState().draftInView).toEqual(threadA); + expect(useThreadTabsStore.getState().inView).toBeNull(); + await act(async () => root.render()); + 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()); + expect(useThreadTabsStore.getState().draftInView).toEqual(threadB); + await act(async () => root.render(

Elsewhere

)); + 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); diff --git a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx index 2a882fe8f84c..b91ae9054a8f 100644 --- a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx +++ b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx @@ -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, @@ -145,13 +148,18 @@ 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(() => { @@ -159,7 +167,13 @@ export function useLazurioThreadTabs( }, [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)}`, @@ -178,7 +192,7 @@ export function useLazurioThreadTabs( stopWatching(); leaveThreadTabs(threadRef); }; - }, [threadRef]); + }, [threadRef, draft]); useEffect(() => { if (threadRef === null) return; @@ -292,9 +306,19 @@ export function useRightPanel(actions: ReadonlyArray) { 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( diff --git a/apps/web/src/lazurio/LazurioNewTab.test.tsx b/apps/web/src/lazurio/LazurioNewTab.test.tsx index 75182281ec36..f47aef1da3be 100644 --- a/apps/web/src/lazurio/LazurioNewTab.test.tsx +++ b/apps/web/src/lazurio/LazurioNewTab.test.tsx @@ -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 } }); @@ -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", () => { @@ -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: [] }); diff --git a/apps/web/src/lazurio/LazurioNewTab.tsx b/apps/web/src/lazurio/LazurioNewTab.tsx index 32a17c8b904f..dc65f3907012 100644 --- a/apps/web/src/lazurio/LazurioNewTab.tsx +++ b/apps/web/src/lazurio/LazurioNewTab.tsx @@ -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 = []; diff --git a/apps/web/src/lazurio/threadTabs.ts b/apps/web/src/lazurio/threadTabs.ts index 21c9cd4e19f6..8db2d327a7fe 100644 --- a/apps/web/src/lazurio/threadTabs.ts +++ b/apps/web/src/lazurio/threadTabs.ts @@ -310,12 +310,16 @@ interface ThreadTabsState { readonly titles: Readonly>; /** 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(() => ({ snapshots: {}, titles: {}, inView: null, + draftInView: null, })); export function rememberTitle(surfaceId: string, title: string | null): void { @@ -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 = @@ -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 From aa890ea95fbfa6f04ec6bf64d0f7535d56890a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 12:20:11 +0200 Subject: [PATCH 2/3] test: the release contract keeps the draft thread's seams ChatView's one hook call now carries the draft flag. RightPanelTabs passes the overlay's line into the empty state, which uses it as the Browser row's reason and as one line under the list (Lazurio/t3code#53). Co-Authored-By: Claude Opus 5.5 --- scripts/lazurio-release-contract.test.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index 7d9351d15635..fa4014c56126 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -476,6 +476,13 @@ NodeTest.test( /case "environment-browser":\n\s+return ;/, ); 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>/, @@ -483,9 +490,10 @@ NodeTest.test( 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( From 06d3aedc433aca9960dcd4317ab489bebbb1975f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 12:20:13 +0200 Subject: [PATCH 3/3] docs: record the draft thread's panel in the fork runbook Explains why a draft opens no tab stream, and what its empty panel says. The stream opens after the first message. The seam list and the pre-release browser check now include it. Co-Authored-By: Claude Opus 5.5 --- docs/operations/lazurio-fork-release.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index 06af45124805..45198a03a490 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -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, @@ -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` @@ -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