From 4e7eef754374db1add645b72b44a1520fd39e3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 12 Oct 2026 20:26:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20the=20right=20panel=20opens=20on=20?= =?UTF-8?q?the=20thread's=20tab,=20not=20on=20Nov=C3=A1=20karta=20beside?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread whose first snapshot already lists its agent's tab (it comes into view, or the page reloads, after the agent opened a page) added the tab to the strip with nothing selected: the first snapshot reveals nothing, and the strip's reconciliation kept an empty selection. The panel then opened on the empty Nová karta beside a "Browser" tab whose frame it never loaded, so the agent's page looked missing. The strip after the server's list now selects its first Environment browser tab where nothing is selected or the selected surface went, as upstream's reconciliation does for its browser, even when the list brings nothing new (a panel stored without a selection). Another Environment's tab added behind follows the same rule. A closed panel stays closed; a live reveal still opens it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/threadTabs.test.ts | 37 +++++++++++++++++++++++ apps/web/src/lazurio/threadTabs.ts | 39 ++++++++++++------------- docs/operations/lazurio-fork-release.md | 6 +++- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/apps/web/src/lazurio/threadTabs.test.ts b/apps/web/src/lazurio/threadTabs.test.ts index a09cfcce330c..815246842a73 100644 --- a/apps/web/src/lazurio/threadTabs.test.ts +++ b/apps/web/src/lazurio/threadTabs.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { toastManager } from "../components/ui/toast"; import { type RightPanelSurface, + selectActiveRightPanelSurface, selectThreadRightPanelState, type ThreadRightPanelState, useRightPanelStore, @@ -21,6 +22,7 @@ import { newTabSurface, parseThreadTabsAnswer, placesOf, + receiveForeign, receiveSnapshot, reconcileThreadTabs, resetThreadTabs, @@ -138,6 +140,25 @@ describe("the strip from the server's list", () => { }); }); + it("selects the first tab where nothing is selected, so the panel never opens on Nová karta beside them", () => { + expect( + reconcile({ isOpen: false, activeSurfaceId: null, surfaces: [] }, [id(1), id(2)]), + ).toEqual({ isOpen: false, activeSurfaceId: tab(1).id, surfaces: [tab(1), tab(2)] }); + // A panel stored with tabs and no selection takes one even when the list brings nothing new. + const unselected = { isOpen: true, activeSurfaceId: null, surfaces: [tab(1)] }; + expect(reconcile(unselected, [id(1)])).toEqual({ ...unselected, activeSurfaceId: tab(1).id }); + // An empty strip stays as it is. + const empty = { isOpen: true, activeSurfaceId: null, surfaces: [] }; + expect(reconcile(empty, [])).toBe(empty); + }); + + it("selects another Environment's tab the server announces where nothing is selected", () => { + setPanel({ isOpen: true, activeSurfaceId: null, surfaces: [] }); + receiveSnapshot(threadA, snapshot([])); + receiveForeign(threadA, { tabId: "ab".repeat(16), view: foreign.view }); + expect(panel()).toEqual({ isOpen: true, activeSurfaceId: foreign.id, surfaces: [foreign] }); + }); + it("drops this Environment's unlisted tabs and keeps pending ones, Nová karta and other Environments' tabs", () => { const newTab = newTabSurface(); const state = { @@ -250,6 +271,22 @@ describe("reveals", () => { expect(panel()).toEqual({ isOpen: true, activeSurfaceId: tab(1).id, surfaces: [tab(1)] }); }); + it("leave the agent's tab of a thread that comes into view selected, for the panel the person opens", () => { + // The agent opened the page while the thread was out of view (or before a reload): its + // reveal is in the first snapshot, a baseline, so the panel does not open by itself... + syncOf(threadA).inlinePanel = true; + receiveSnapshot( + threadA, + snapshot([{ tabId: id(1), agent: true, revealRequest: { id: "epoch-1:1" } }]), + ); + expect(panel()).toEqual({ isOpen: false, activeSurfaceId: tab(1).id, surfaces: [tab(1)] }); + // ...and when the person opens it, it shows the agent's page, not Nová karta. + useRightPanelStore.getState().toggleVisibility(threadA); + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadA), + ).toEqual(tab(1)); + }); + it("give way to a choice the person made since the snapshot came", () => { syncOf(threadA).inlinePanel = true; setPanel({ isOpen: true, activeSurfaceId: files.id, surfaces: [files] }); diff --git a/apps/web/src/lazurio/threadTabs.ts b/apps/web/src/lazurio/threadTabs.ts index 8db2d327a7fe..ba9710487bc5 100644 --- a/apps/web/src/lazurio/threadTabs.ts +++ b/apps/web/src/lazurio/threadTabs.ts @@ -154,7 +154,10 @@ export function parseForeignTab(value: unknown): ForeignTab | null { * `kept` (pending holds, tabs the device keeps itself) has. Every other Environment browser * surface goes, lazurio.7's included (its own tab and its new tabs at the view's bare origin). * Listed tabs without a surface are added at the end, in the list's order, without opening a - * closed panel. An active surface that went falls back as upstream's reconciliation does. + * closed panel. Where nothing is selected, or the selected surface went, the first Environment + * browser tab is selected, as upstream's reconciliation does for its browser: the panel never + * opens on the empty Nová karta beside tabs of the strip whose frames it would not load (a thread + * that comes into view after its agent opened a page, whose first snapshot reveals nothing). */ export function reconcileThreadTabs( current: ThreadRightPanelState, @@ -181,20 +184,18 @@ export function reconcileThreadTabs( id: tabSurfaceId(tabId), kind: "environment-browser", })); - if (added.length === 0 && surfaces.length === current.surfaces.length) return current; const next = [...surfaces, ...added]; - const activeStillExists = next.some((surface) => surface.id === current.activeSurfaceId); - return { - ...current, - surfaces: next, - activeSurfaceId: activeStillExists - ? current.activeSurfaceId - : current.activeSurfaceId === null - ? null - : (next.find((surface) => surface.kind === "environment-browser")?.id ?? - next[0]?.id ?? - null), - }; + const activeSurfaceId = next.some((surface) => surface.id === current.activeSurfaceId) + ? current.activeSurfaceId + : (next.find((surface) => surface.kind === "environment-browser")?.id ?? next[0]?.id ?? null); + if ( + added.length === 0 && + surfaces.length === current.surfaces.length && + activeSurfaceId === current.activeSurfaceId + ) { + return current; + } + return { ...current, surfaces: next, activeSurfaceId }; } /** What the panel has seen of the server's reveal requests, per run of the server (`epoch`). */ @@ -428,7 +429,9 @@ const panelOf = (threadRef: ScopedThreadRef) => const hasSurface = (threadRef: ScopedThreadRef, surfaceId: string) => panelOf(threadRef).surfaces.some((surface) => surface.id === surfaceId); -/** Adds a surface, in front with the panel open, or behind the surface in view. */ +/** Adds a surface, in front with the panel open, or behind the surface in view. Where nothing is + * selected it is the one selected, as in the strip after the server's list, and a closed panel + * stays closed. */ function withSurface( current: ThreadRightPanelState, surface: EnvironmentBrowserSurface, @@ -439,11 +442,7 @@ function withSurface( : [...current.surfaces, surface]; return front ? { ...current, isOpen: true, surfaces, activeSurfaceId: surface.id } - : { - ...current, - surfaces, - activeSurfaceId: current.activeSurfaceId ?? (current.isOpen ? null : surface.id), - }; + : { ...current, surfaces, activeSurfaceId: current.activeSurfaceId ?? surface.id }; } /** Removes a surface the way upstream closes one: the neighbour comes to the front. */ diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index f36fc8d93d0e..6ba73639231c 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -355,7 +355,11 @@ T3 žádný prohlížeč nemá, a tak overlay dělá tohle: servíruje, sleduje stránka seznam vlákna a každé zařízení srovná svůj pás s tímtéž seznamem. Nové záložky přidá na konec v pořadí souboru a neotevře jimi zavřený panel; kde už záložku ukazuje, zůstává volbou zařízení (záložky jde - přetahovat, i povrchy overlaye). Povrch je `environment-browser:` (id + přetahovat, i povrchy overlaye). Kde není vybraný žádný povrch, nebo vybraný + zmizel, vybere první záložku prohlížeče jako upstream srovnání svého + prohlížeče, takže se panel otevře na ní, a ne na prázdné Nové kartě vedle + záložek, jejichž rámy by nenačetl (vlákno přijde do zobrazení až po tom, co + agent stránku otevřel). Povrch je `environment-browser:` (id velkými písmeny) bez adresy; rám ukazuje `/t/` s originem ze snímku. Jen záložka jiného Environmentu má uloženou adresu (`view`). Čekající podržení srovnání nezahodí, pozůstatky lazurio.7 (vlastní záložka vlákna, nové