Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}
Expand Down
39 changes: 31 additions & 8 deletions apps/web/src/components/chat/ChatHeader.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function renderChatHeader(overrides: Partial<ComponentProps<typeof ChatHeader>>
terminalToggleShortcutLabel: null,
railToggleShortcutLabel: null,
railOpen: false,
railTabs: [],
sourceControlAvailable: false,
browserAvailable: true,
browserOpen: false,
Expand Down Expand Up @@ -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 },
});

Expand All @@ -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 },
Expand All @@ -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", () => {
Expand All @@ -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 },
});

Expand Down
62 changes: 36 additions & 26 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,30 +50,36 @@ 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<RightPanelTab>;
/** False for capability-gated threads (General Chats) even when a project
* name exists: the rail still opens, just without its Source tab. */
sourceControlAvailable: boolean;
/** False where there is no project to preview, e.g. a general chat. */
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. */
Expand Down Expand Up @@ -151,6 +158,7 @@ export const ChatHeader = memo(function ChatHeader({
terminalToggleShortcutLabel,
railToggleShortcutLabel,
railOpen,
railTabs,
sourceControlAvailable,
browserAvailable,
browserOpen,
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -381,11 +395,7 @@ export const ChatHeader = memo(function ChatHeader({
// on both sides -- the icon otherwise sits against the
// hover fill -- and less between them: the base gap is
// sized for icons, not for a label that belongs to one.
(workingTreeDiffStat !== null ||
remoteBehindCount !== null ||
liveAgents !== null) &&
!railOpen &&
"gap-1 px-1.5",
(showSourceCounts || liveAgentsOnToggle !== null) && "gap-1 px-1.5",
)}
pressed={railOpen}
onPressedChange={onToggleRail}
Expand All @@ -394,10 +404,10 @@ export const ChatHeader = memo(function ChatHeader({
size="xs"
>
<PanelRightIcon className="size-3" />
{/* 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 ? (
<span className="font-mono text-[10px] leading-none">
{workingTreeDiffStat ? (
<>
Expand All @@ -424,27 +434,27 @@ 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 ? (
<span
className="inline-flex shrink-0 items-center gap-0.5"
data-header-live-agents={
liveAgents.waitingCount > 0 ? "waiting" : "running"
liveAgentsOnToggle.waitingCount > 0 ? "waiting" : "running"
}
>
{liveAgents.waitingCount > 0 ? (
{liveAgentsOnToggle.waitingCount > 0 ? (
<span
aria-hidden="true"
className="block size-1.5 rounded-full bg-amber-500"
/>
) : (
<LiveNode className="size-1.5" />
)}
{liveAgents.count > 1 ? (
{liveAgentsOnToggle.count > 1 ? (
<span
className="font-mono text-[10px] leading-none text-muted-foreground"
data-header-live-agents-count="true"
>
{liveAgents.count}
{liveAgentsOnToggle.count}
</span>
) : null}
</span>
Expand All @@ -454,12 +464,12 @@ export const ChatHeader = memo(function ChatHeader({
/>
<TooltipPopup side="bottom">
{railToggleShortcutLabel ? `Panel (${railToggleShortcutLabel})` : "Panel"}
{!railOpen && liveAgents ? (
{liveAgentsOnToggle ? (
<div className="text-muted-foreground">
{formatLiveAgentsTooltip(liveAgents)} Open the Agents tab.
{formatLiveAgentsTooltip(liveAgentsOnToggle)} Open the Agents tab.
</div>
) : null}
{!railOpen && sourceControlAvailable && remoteBehindCount !== null ? (
{showSourceCounts && sourceControlAvailable && remoteBehindCount !== null ? (
<div className="text-muted-foreground">
{remoteBehindCount === 1
? "1 commit behind the remote."
Expand Down
Loading