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é