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