From 570f5f62c53aef79ed48167812519f7298bf067b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 8 Aug 2026 23:25:10 -0400 Subject: [PATCH] fix: context ring always opens context stats panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented the circular ring from reliably switching to the context stats tab: 1. session-context-usage: contextVisible used a local tabState.activeTab() derived from createSessionTabs with a missing review accessor. When the active tab was 'review', the fallback logic returned 'context' if it was still in all(), causing the ring to close instead of open. Fixed by reading tabs().active() directly. 2. session-side-panel: the context Tabs.Trigger was gated behind , which unmounts the trigger when context is closed. When openSessionTab simultaneously adds 'context' to all and sets active='context', Kobalte processes the value change before the Show mounts the trigger, so it can't find it. Fixed by replacing Show with display:none on a wrapper div — the trigger stays registered with Kobalte at all times. Added a test exercising the close-then-reopen cycle to verify the state transforms produce the correct activeTab at each step. --- .../src/components/session-context-usage.tsx | 16 +---- .../app/src/pages/session/helpers.test.ts | 63 +++++++++++++++++++ .../src/pages/session/session-side-panel.tsx | 8 +-- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c1e0ccde0..9b31e36dd 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -4,9 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Button } from "@opencode-ai/ui/button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { createMediaQuery } from "@solid-primitives/media" -import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" @@ -14,8 +12,6 @@ import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { getSessionContext } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" -import { useSettings } from "@/context/settings" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -45,23 +41,15 @@ function openSessionContext(args: { export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() - const file = useFile() const layout = useLayout() const language = useLanguage() const sdk = useSDK() - const settings = useSettings() const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() - const isDesktop = createMediaQuery("(min-width: 768px)") const variant = createMemo(() => props.variant ?? "button") const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") - const tabState = createSessionTabs({ - tabs, - pathFromTab: file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), - fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, - }) + const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) @@ -77,7 +65,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) - const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") + const contextVisible = createMemo(() => view().reviewPanel.opened() && tabs().active() === "context") const hasOtherTabs = createMemo(() => tabs() .all() diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index c04502562..548bc909c 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -10,6 +10,7 @@ import { getTabReorderIndex, shouldShowFileTree, } from "./helpers" +import { closeSessionTab, openSessionTab } from "@/context/layout-tabs" describe("shouldShowFileTree", () => { test("does not reserve space for a disabled file tree", () => { @@ -211,4 +212,66 @@ describe("createSessionTabs", () => { dispose() }) }) + + test("close context then re-open: activeTab returns context", () => { + // Step 1: context is open and active + const initial = { tabs: { all: ["context"], active: "context" as string | undefined }, preview: undefined } + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => initial.tabs.active, all: () => initial.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + expect(result.activeTab()).toBe("context") + expect(result.contextOpen()).toBe(true) + dispose() + }) + + // Step 2: close context — active falls to review + const afterClose = closeSessionTab(initial, "context") + expect(afterClose.tabs.all).toEqual([]) + expect(afterClose.tabs.active).toBeUndefined() + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterClose.tabs.active, all: () => afterClose.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + // Context is gone — activeTab falls to "review" via the hasReview fallback + expect(result.contextOpen()).toBe(false) + expect(result.activeTab()).toBe("review") + dispose() + }) + + // Step 3: re-open context — simulates openSessionContext calling tabs.open("context") + const afterReopen = openSessionTab( + { tabs: afterClose.tabs, preview: afterClose.preview }, + "context", + ) + expect(afterReopen.tabs.all).toContain("context") + expect(afterReopen.tabs.active).toBe("context") + + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => afterReopen.tabs.active, all: () => afterReopen.tabs.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + // After re-open: activeTab MUST be "context" + expect(result.contextOpen()).toBe(true) + expect(result.activeTab()).toBe("context") + dispose() + }) + }) }) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index bc13f5369..e04dff413 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -390,7 +390,7 @@ export function SessionSidePanel(props: { docs/adr/0001). Do not re-add a tab here: two hosts mirrored through two stores was the desync this column's toggle got blamed for. */} - +
{language.t("session.tab.context")}
-
+ {(tab) => ( @@ -598,7 +598,7 @@ export function SessionSidePanel(props: { : language.t("session.tab.review")}
- +
{language.t("session.tab.context")}
-
+ {(tab) => (