diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c81ec606..d4570079 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 266c8894..ad0fe54a 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 12b669bd..a28265aa 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."