diff --git a/CHANGELOG.md b/CHANGELOG.md index ec56bedc..cc7d19e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. explains that it belongs after a supporting subcommand, replacing the misleading `--version` suggestion and usage line ([#387](https://github.com/Tencent/BrowserSkill/issues/387)). +- DSH Plugin: the right-sidebar observation tab auto-opens again on DSH + 0.2.0+, which removed `current` from the `sessions.list` snapshot. The + auto-open target now falls back to the `uiSession` main binding (exposed by + DSH 0.1.5 and 0.2.x alike), and conversation switches on 0.2.x hosts keep + re-triggering it + ([#484](https://github.com/Tencent/BrowserSkill/issues/484)). ## [0.3.3] - 2026-10-14 diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 89e7d028..6cbba60a 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -43,8 +43,28 @@ export interface NativeSidebarSlots { ): () => void; } +/** + * Session catalog list, narrowed to the fields the auto-open reads. + * DSH 0.2.0 removed `current` from this snapshot (view selection moved to + * the uiSession service); it stays optional so one shape covers all hosts. + */ export interface SessionListSource { - getSnapshot(): { current: string | undefined }; + getSnapshot(): { current?: string | undefined }; + subscribe(listener: () => void): () => void; +} + +/** Reading of the uiSession service's renderer-facing main-binding adapter. */ +export interface UiSessionService { + adapter?: { + /** Main-view session binding; `key` is its session id, undefined when absent. */ + current?: MainSessionBindingSource; + }; + current?: MainSessionBindingSource; +} + +/** `key`-addressed binding source exposed by uiSession on DSH 0.1.5 and 0.2.x alike. */ +export interface MainSessionBindingSource { + getSnapshot(): { key?: string | undefined }; subscribe(listener: () => void): () => void; } @@ -167,6 +187,22 @@ export function registerObservationSidebar( | { register(definition: NativeTabDefinition): () => void } | undefined; const sessions = (host.get("sessions") as { list?: SessionListSource } | undefined)?.list; + // DSH 0.2.0+ removed `current` from the session list; the uiSession service + // owns the main-view binding instead. Optional on every host: 0.1.5 exposes + // it through adapter.current as well, so either shape can drive the fallback. + const mainSession = (host.get("uiSession") as UiSessionService | undefined) ?? undefined; + const mainBinding = + mainSession?.adapter?.current ?? + (mainSession && "current" in mainSession ? mainSession.current : undefined); + const mainBindingUsable = + typeof mainBinding?.getSnapshot === "function" && typeof mainBinding?.subscribe === "function"; + /** Prefer the list's own selection (0.1.5); fall back to the main binding (0.2.x). */ + const currentSession = (): string | undefined => { + const listed = sessions?.getSnapshot().current; + if (listed !== undefined) return listed; + if (!mainBindingUsable) return undefined; + return mainBinding.getSnapshot().key; + }; if ( typeof sidebar?.openTabIn !== "function" || typeof tabs?.register !== "function" || @@ -227,10 +263,7 @@ export function registerObservationSidebar( const attempt = () => { if (disposed || pending !== request) return; try { - if ( - sessions.getSnapshot().current !== sessionId || - store.presentation.getSnapshot().floating - ) { + if (currentSession() !== sessionId || store.presentation.getSnapshot().floating) { cancelOpen(); return; } @@ -255,7 +288,7 @@ export function registerObservationSidebar( }; function reveal() { try { - const current = sessions?.getSnapshot().current; + const current = currentSession(); if (current !== undefined) open(current); } catch (error) { fail(error); @@ -275,7 +308,7 @@ export function registerObservationSidebar( }; const evaluate = () => { if (disposed) return; - const current = sessions.getSnapshot().current; + const current = currentSession(); const observations = store.getSnapshot().sessions; for (const id of autoOpened) { if (!observations.some((session) => visibleToScope(session, id))) autoOpened.delete(id); @@ -352,6 +385,9 @@ export function registerObservationSidebar( store.acquire(); disposers.push(store.subscribe(schedule)); disposers.push(sessions.subscribe(schedule)); + // DSH 0.2.0+: conversation switches update the uiSession main binding + // without necessarily touching the session list, so follow it too. + if (mainBindingUsable) disposers.push(mainBinding.subscribe(schedule)); disposers.push(store.presentation.subscribe(schedule)); connect(); schedule(); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index 41bdbecf..10ed4b4a 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -60,14 +60,23 @@ function observation(initial: SessionObservation[] = []) { /** Native contracts: keyed slots use implementation id, navigation uses kind and explicit session. */ function nativeHost( - options: { ready?: boolean; declared?: boolean; betterSidebar?: boolean } = {}, + options: { + ready?: boolean; + declared?: boolean; + betterSidebar?: boolean; + listCurrent?: boolean; + } = {}, ) { let current: string | undefined = "conv-1"; let ready = options.ready ?? true; + // DSH 0.2.0+ removed `current` from the session list; mirror that shape when + // listCurrent is false and expose the uiSession main binding instead. + const listCarriesCurrent = options.listCurrent ?? true; const declared = new Set(options.declared === false ? [] : [BODY, TITLE]); const watchers = new Set<{ name: string; start: () => () => void; stop?: () => void }>(); const components = new Map ReactNode>(); const listeners = new Set<() => void>(); + const bindingListeners = new Set<() => void>(); const roots = new Map>(); let definition: NativeTabDefinition | undefined; const register = vi.fn((value: NativeTabDefinition) => { @@ -97,13 +106,28 @@ function nativeHost( sidebarRightTabs: { register }, sessions: { list: { - getSnapshot: () => ({ current }), + getSnapshot: () => (listCarriesCurrent ? { current } : { ids: [], byId: {} }), subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, }, }, + ...(listCarriesCurrent + ? {} + : { + uiSession: { + adapter: { + current: { + getSnapshot: () => ({ key: current }), + subscribe(listener: () => void) { + bindingListeners.add(listener); + return () => bindingListeners.delete(listener); + }, + }, + }, + }, + }), ...(options.betterSidebar ? { betterSidebar } : {}), }; const host: NativeSidebarHost = { @@ -140,7 +164,11 @@ function nativeHost( }, switchSession(id: string | undefined) { current = id; - for (const listener of [...listeners]) listener(); + if (listCarriesCurrent) { + for (const listener of [...listeners]) listener(); + } else { + for (const listener of [...bindingListeners]) listener(); + } }, close(sessionId: string) { roots.get(sessionId)?.unmount(); @@ -274,6 +302,43 @@ describe("automatic presentation", () => { expect(n.openTabIn).toHaveBeenCalledTimes(2); }); + it("auto-opens through the uiSession main binding when the session list has no current (DSH 0.2.0+)", async () => { + const h = observation([BUSY, FOREIGN]); + const n = nativeHost({ listCurrent: false }); + install(n.host, h.store); + await waitFor(() => + expect(n.openTabIn).toHaveBeenCalledExactlyOnceWith("conv-1", OBSERVATION_TAB_KIND), + ); + await act(async () => n.switchSession("conv-2")); + expect(n.openTabIn).toHaveBeenLastCalledWith("conv-2", OBSERVATION_TAB_KIND); + await act(async () => n.switchSession("conv-1")); + expect(n.openTabIn).toHaveBeenCalledTimes(2); + }); + + it("waits for a selected main binding conversation before opening on DSH 0.2.0+", async () => { + const h = observation([FOREIGN]); + const n = nativeHost({ listCurrent: false }); + n.switchSession(undefined); + install(n.host, h.store); + await act(async () => {}); + expect(n.openTabIn).not.toHaveBeenCalled(); + await act(async () => n.switchSession("conv-1")); + expect(n.openTabIn).not.toHaveBeenCalled(); + await act(async () => h.push([BUSY])); + expect(n.openTabIn).toHaveBeenCalledTimes(1); + }); + + it("registers without any current-session source but never auto-opens", async () => { + const h = observation([BUSY]); + const n = nativeHost({ listCurrent: false }); + delete n.values.uiSession; + install(n.host, h.store); + await act(async () => {}); + expect(n.register).toHaveBeenCalled(); + expect(n.openTabIn).not.toHaveBeenCalled(); + expect(h.store.presentation.getSnapshot().sidebarAvailable).toBe(true); + }); + it("waits for a selected conversation with its own browser and opens again for a new task", async () => { const h = observation([FOREIGN]); const n = nativeHost();