From 258c69feab09c06b5c145ff6f39edd52766c9c38 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:25:41 -0400 Subject: [PATCH] fix(web): panel button keeps its counts until the tab that shows them is open The header's panel button only showed the working-tree diffstat, the behind-remote count and the live-agent node while the whole sidebar was hidden. Opening the sidebar on the Agents tab alone dropped the diffstat even though no Source tab was on screen, and the same in reverse. The button now reads the sidebar's tab strip. The Source counts stay until a Source tab is in the strip, and the live node stays until an Agents tab is, since that tab draws its own. --- apps/web/src/components/ChatView.tsx | 8 ++- .../chat/ChatHeader.render.test.tsx | 39 +++++++++--- apps/web/src/components/chat/ChatHeader.tsx | 62 +++++++++++-------- 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c81ec606b..d45700791 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1350,10 +1350,15 @@ export default function ChatView(props: ChatViewProps) { // The sidebar's tab strip is owned by the route that renders it; the header // reads the same store so its panel button and activity chip agree with what // is on screen. The button reflects the sidebar as a whole — it stays pressed - // on any tab, and on the launcher. + // on any tab, and on the launcher — and its counts stand in for whichever + // tabs the strip is showing without. const rightPanelTabs = useRightPanelTabs(rightPanelStateKey); const rightPanelEngaged = rightPanelTabs.visible; const agentsPanelOpen = rightPanelTabs.activeTab === "agents"; + const railTabs = useMemo( + () => (rightPanelTabs.visible ? rightPanelTabs.openTabs : []), + [rightPanelTabs.visible, rightPanelTabs.openTabs], + ); const activeThreadId = activeThread?.id ?? null; const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), @@ -6604,6 +6609,7 @@ export default function ChatView(props: ChatViewProps) { terminalToggleShortcutLabel={terminalToggleShortcutLabel} railToggleShortcutLabel={sourceControlPanelShortcutLabel} railOpen={rightPanelEngaged} + railTabs={railTabs} sourceControlAvailable={activeProject !== undefined && !isGeneralChatThread} browserAvailable={browserAvailable} browserOpen={browserOpen} diff --git a/apps/web/src/components/chat/ChatHeader.render.test.tsx b/apps/web/src/components/chat/ChatHeader.render.test.tsx index 266c8894a..ad0fe54a2 100644 --- a/apps/web/src/components/chat/ChatHeader.render.test.tsx +++ b/apps/web/src/components/chat/ChatHeader.render.test.tsx @@ -24,6 +24,7 @@ function renderChatHeader(overrides: Partial> terminalToggleShortcutLabel: null, railToggleShortcutLabel: null, railOpen: false, + railTabs: [], sourceControlAvailable: false, browserAvailable: true, browserOpen: false, @@ -86,21 +87,33 @@ describe("ChatHeader", () => { expect(markup).toContain("cursor-default"); }); - it("shows the working-tree diffstat on the closed rail toggle", () => { - const markup = renderChatHeader({ + it("shows the working-tree diffstat on the rail toggle while no Source tab is showing", () => { + const closed = renderChatHeader({ sourceControlAvailable: true, railOpen: false, workingTreeDiffStat: { insertions: 38, deletions: 12 }, }); - expect(markup).toContain("+38"); - expect(markup).toContain("−12"); + expect(closed).toContain("+38"); + expect(closed).toContain("−12"); + + // Open on other tabs, the strip has nowhere else to show the total. + const openElsewhere = renderChatHeader({ + sourceControlAvailable: true, + railOpen: true, + railTabs: ["agents", "diff"], + workingTreeDiffStat: { insertions: 38, deletions: 12 }, + }); + + expect(openElsewhere).toContain("+38"); + expect(openElsewhere).toContain("−12"); }); - it("drops the diffstat once the rail is open and shows its own counts", () => { + it("drops the diffstat once a Source tab is in the strip to show its own counts", () => { const markup = renderChatHeader({ sourceControlAvailable: true, railOpen: true, + railTabs: ["agents", "sourceControl"], workingTreeDiffStat: { insertions: 38, deletions: 12 }, }); @@ -118,17 +131,18 @@ describe("ChatHeader", () => { expect(markup).toContain("↓2"); }); - it("drops the behind-remote count once the rail is open", () => { + it("drops the behind-remote count once a Source tab is in the strip", () => { const markup = renderChatHeader({ sourceControlAvailable: true, railOpen: true, + railTabs: ["sourceControl"], remoteBehindCount: 2, }); expect(markup).not.toContain("↓2"); }); - it("nodes the closed rail toggle while agents are live, counting past one", () => { + it("nodes the rail toggle while agents are live and no Agents tab is showing", () => { const single = renderChatHeader({ railOpen: false, liveAgents: { count: 1, waitingCount: 0 }, @@ -144,6 +158,14 @@ describe("ChatHeader", () => { expect(several).toContain('data-header-live-agents="running"'); expect(several).toContain('data-header-live-agents-count="true"'); expect(several).toContain(">2<"); + + // Open on the Source tab alone, nothing in the strip says an agent is live. + const openElsewhere = renderChatHeader({ + railOpen: true, + railTabs: ["sourceControl"], + liveAgents: { count: 1, waitingCount: 0 }, + }); + expect(openElsewhere).toContain('data-header-live-agents="running"'); }); it("turns the node amber when an agent is waiting on the user", () => { @@ -156,9 +178,10 @@ describe("ChatHeader", () => { expect(markup).toContain("bg-amber-500"); }); - it("drops the live-agent node once the rail is open, where the Agents tab owns it", () => { + it("drops the live-agent node once an Agents tab is in the strip, where that tab owns it", () => { const markup = renderChatHeader({ railOpen: true, + railTabs: ["sourceControl", "agents"], liveAgents: { count: 2, waitingCount: 0 }, }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 12b669bdd..a28265aa8 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -29,6 +29,7 @@ import type { ThreadBackgroundRunItem } from "./threadActivity"; import type { LiveAgentIndicator } from "./agentsPanel.logic"; import { LiveNode } from "../ui/threadline"; import { cn } from "../../lib/utils"; +import type { RightPanelTab } from "../../rightPanelTabs"; export interface ForkHeaderContext { readonly sourceThreadId: ThreadId; @@ -49,8 +50,14 @@ interface ChatHeaderProps { terminalOpen: boolean; terminalToggleShortcutLabel: string | null; railToggleShortcutLabel: string | null; - /** Whether the right rail is showing, on either of its tabs. */ + /** Whether the right rail is showing, on any of its tabs or the launcher. */ railOpen: boolean; + /** + * The tabs in the rail's strip while it is showing; empty while it is hidden. + * The toggle repeats only what the strip does not already carry: the diffstat + * and behind count belong to the Source tab, the live node to the Agents tab. + */ + railTabs: ReadonlyArray; /** False for capability-gated threads (General Chats) even when a project * name exists: the rail still opens, just without its Source tab. */ sourceControlAvailable: boolean; @@ -58,21 +65,21 @@ interface ChatHeaderProps { browserAvailable: boolean; browserOpen: boolean; /** - * Working-tree diffstat, surfaced on the closed rail toggle so the size of - * the pending change is legible without opening the rail. Null when the tree - * is clean or the status has not loaded. + * Working-tree diffstat, surfaced on the rail toggle while no Source tab is + * showing, so the size of the pending change is legible without opening one. + * Null when the tree is clean or the status has not loaded. */ workingTreeDiffStat: { readonly insertions: number; readonly deletions: number } | null; /** - * Commits the branch is behind its upstream, surfaced on the closed rail - * toggle as a pull-available hint. Null when there is nothing to pull or the - * status has not loaded. + * Commits the branch is behind its upstream, surfaced on the rail toggle as a + * pull-available hint while no Source tab is showing. Null when there is + * nothing to pull or the status has not loaded. */ remoteBehindCount: number | null; /** - * Agents running right now, surfaced on the closed rail toggle the same way - * the diffstat is. Null when nothing is live. While the rail is open its Agents - * tab carries the live node itself, so these stay closed-only. + * Agents running right now, surfaced on the rail toggle the same way the + * diffstat is. Null when nothing is live. An open Agents tab carries the live + * node itself, so the toggle drops it while that tab is in the strip. */ liveAgents: LiveAgentIndicator | null; /** False for General Chats: their scratch workspace has no files worth browsing. */ @@ -151,6 +158,7 @@ export const ChatHeader = memo(function ChatHeader({ terminalToggleShortcutLabel, railToggleShortcutLabel, railOpen, + railTabs, sourceControlAvailable, browserAvailable, browserOpen, @@ -186,6 +194,12 @@ export const ChatHeader = memo(function ChatHeader({ const continueInProjectState = resolveContinueInProjectHeaderState( continueInProjectDisabledReason, ); + // The rail toggle repeats only what the strip does not already carry: a + // Source tab lists the per-file counts, an Agents tab draws its own live node. + const showSourceCounts = + !railTabs.includes("sourceControl") && + (workingTreeDiffStat !== null || remoteBehindCount !== null); + const liveAgentsOnToggle = railTabs.includes("agents") ? null : liveAgents; return (
- {/* Only while closed: once the rail is open its Source tab - shows the per-file counts, and repeating the total is + {/* Only while no Source tab is showing: that tab lists the + per-file counts, and repeating the total beside it is noise. */} - {!railOpen && (workingTreeDiffStat || remoteBehindCount !== null) ? ( + {showSourceCounts ? ( {workingTreeDiffStat ? ( <> @@ -424,14 +434,14 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} {/* Typographic, like the counts beside it: a node and at most a digit. An agent waiting on the user turns it amber. */} - {!railOpen && liveAgents ? ( + {liveAgentsOnToggle ? ( 0 ? "waiting" : "running" + liveAgentsOnToggle.waitingCount > 0 ? "waiting" : "running" } > - {liveAgents.waitingCount > 0 ? ( + {liveAgentsOnToggle.waitingCount > 0 ? ( @@ -454,12 +464,12 @@ export const ChatHeader = memo(function ChatHeader({ /> {railToggleShortcutLabel ? `Panel (${railToggleShortcutLabel})` : "Panel"} - {!railOpen && liveAgents ? ( + {liveAgentsOnToggle ? (
- {formatLiveAgentsTooltip(liveAgents)} Open the Agents tab. + {formatLiveAgentsTooltip(liveAgentsOnToggle)} Open the Agents tab.
) : null} - {!railOpen && sourceControlAvailable && remoteBehindCount !== null ? ( + {showSourceCounts && sourceControlAvailable && remoteBehindCount !== null ? (
{remoteBehindCount === 1 ? "1 commit behind the remote."