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 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 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(