Skip to content

Commit ebab1bb

Browse files
committed
feat(web): introduce shared panel header
1 parent 6b1c796 commit ebab1bb

8 files changed

Lines changed: 343 additions & 160 deletions

File tree

packages/web/src/features/agent-panes/components/session-card.test.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,15 +247,20 @@ describe("SessionCard", () => {
247247
</Provider>
248248
);
249249

250-
const header = screen.getByText("SESSION-56").closest(".session-header");
250+
const bespokeHeader = screen.getByText("SESSION-56").closest(".session-header");
251+
const header = screen.getByText("SESSION-56").closest(".panel-header");
252+
const actions = header?.querySelector(".panel-header__actions");
251253
const accessory = screen.getByRole("button", { name: "Supervisor entry" });
252254
const right = header?.querySelector(".session-header-right");
253255

256+
expect(bespokeHeader).toBeNull();
254257
expect(header).not.toBeNull();
258+
expect(actions).not.toBeNull();
255259
expect(accessory.parentElement).toHaveClass("session-header-accessory");
256260
expect(right).not.toBeNull();
257261
expect(right).toContainElement(accessory);
258-
expect(header?.lastElementChild).toBe(right);
262+
expect(actions).toContainElement(right as HTMLElement);
263+
expect(header?.lastElementChild).toBe(actions);
259264
});
260265

261266
it("forces the terminal read-only when terminalReadOnlyOverride is true", () => {

packages/web/src/features/agent-panes/views/shared/session-card.tsx

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { pendingFocusSessionAtom } from "../../../../atoms/app-ui";
1414
import { sessionByIdAtomFamily } from "../../../../atoms/sessions";
1515
import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces";
1616
import { IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui";
17+
import { PanelHeader } from "../../../shared/components/panel-header";
1718
import { useSupervisor } from "../../../supervisor/actions/use-supervisor";
1819
import { ObjectiveDialog } from "../../../supervisor/views/shared/objective-dialog";
1920
import { SupervisorCard } from "../../../supervisor/views/shared/supervisor-card";
@@ -117,70 +118,71 @@ export const SessionCard: FC<SessionCardProps> = ({
117118
data-session-id={sessionId}
118119
onClick={handleCardClick}
119120
>
120-
<div className="session-header">
121-
<div className="session-header-left">
121+
<PanelHeader
122+
title={sessionTitle}
123+
status={
122124
<StatusDot
123125
tone={getSessionDotTone(session.state)}
124126
pulse={shouldPulseSessionDot(session.state)}
125127
className={`session-dot ${getSessionDotClass(session.state)}`}
126128
/>
127-
<div className="session-header-copy">
128-
<div className="session-title-row">
129-
<span className="session-title">{sessionTitle}</span>
130-
<Tag color="blue" className="session-provider-badge">
131-
{providerLabel}
132-
</Tag>
133-
<Tag
134-
color={getSessionTagColor(session.state)}
135-
className="session-state-badge"
136-
caps={false}
137-
>
138-
{sessionStateLabel}
139-
</Tag>
140-
</div>
129+
}
130+
meta={
131+
<div className="session-title-row">
132+
<Tag color="blue" className="session-provider-badge">
133+
{providerLabel}
134+
</Tag>
135+
<Tag
136+
color={getSessionTagColor(session.state)}
137+
className="session-state-badge"
138+
caps={false}
139+
>
140+
{sessionStateLabel}
141+
</Tag>
141142
</div>
142-
</div>
143-
144-
{showHeaderActions || headerAccessory ? (
145-
<div className="session-header-right">
146-
{headerAccessory ? (
147-
<div className="session-header-accessory">{headerAccessory}</div>
148-
) : null}
143+
}
144+
actions={
145+
showHeaderActions || headerAccessory ? (
146+
<div className="session-header-right">
147+
{headerAccessory ? (
148+
<div className="session-header-accessory">{headerAccessory}</div>
149+
) : null}
149150

150-
{showHeaderActions ? (
151-
<div className="session-header-actions">
152-
<Tooltip content="Split horizontal">
153-
<IconButton
154-
aria-label="Split horizontal"
155-
className="session-action-btn"
156-
icon={<FlipHorizontal size={13} />}
157-
onClick={() => onSplitHorizontal?.()}
158-
size="sm"
159-
/>
160-
</Tooltip>
161-
<Tooltip content="Split vertical">
162-
<IconButton
163-
aria-label="Split vertical"
164-
className="session-action-btn"
165-
icon={<FlipVertical size={13} />}
166-
onClick={() => onSplitVertical?.()}
167-
size="sm"
168-
/>
169-
</Tooltip>
170-
<Tooltip content="Close">
171-
<IconButton
172-
aria-label="Close"
173-
className="session-action-btn session-action-btn-close"
174-
icon={<X size={14} />}
175-
onClick={() => void onClose?.()}
176-
size="sm"
177-
/>
178-
</Tooltip>
179-
</div>
180-
) : null}
181-
</div>
182-
) : null}
183-
</div>
151+
{showHeaderActions ? (
152+
<div className="session-header-actions">
153+
<Tooltip content="Split horizontal">
154+
<IconButton
155+
aria-label="Split horizontal"
156+
className="session-action-btn"
157+
icon={<FlipHorizontal size={13} />}
158+
onClick={() => onSplitHorizontal?.()}
159+
size="sm"
160+
/>
161+
</Tooltip>
162+
<Tooltip content="Split vertical">
163+
<IconButton
164+
aria-label="Split vertical"
165+
className="session-action-btn"
166+
icon={<FlipVertical size={13} />}
167+
onClick={() => onSplitVertical?.()}
168+
size="sm"
169+
/>
170+
</Tooltip>
171+
<Tooltip content="Close">
172+
<IconButton
173+
aria-label="Close"
174+
className="session-action-btn session-action-btn-close"
175+
icon={<X size={14} />}
176+
onClick={() => void onClose?.()}
177+
size="sm"
178+
/>
179+
</Tooltip>
180+
</div>
181+
) : null}
182+
</div>
183+
) : null
184+
}
185+
/>
184186

185187
{showSupervisorInline &&
186188
session.capability === "full" &&
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { render, screen, within } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { PanelHeader } from "./panel-header";
4+
5+
describe("PanelHeader", () => {
6+
it("keeps title and meta grouped on the left while actions stay on the right", () => {
7+
render(
8+
<PanelHeader
9+
title="Session"
10+
meta={<span>Claude</span>}
11+
actions={<button type="button">Close</button>}
12+
/>
13+
);
14+
15+
const header = document.querySelector(".panel-header");
16+
const leading = header?.querySelector(".panel-header__leading");
17+
const actions = header?.querySelector(".panel-header__actions");
18+
19+
expect(header).not.toBeNull();
20+
expect(leading).not.toBeNull();
21+
expect(actions).not.toBeNull();
22+
expect(within(leading as HTMLElement).getByText("Session")).toBeInTheDocument();
23+
expect(within(leading as HTMLElement).getByText("Claude")).toBeInTheDocument();
24+
expect(
25+
within(actions as HTMLElement).getByRole("button", { name: "Close" })
26+
).toBeInTheDocument();
27+
});
28+
29+
it("renders status inline with the title copy without requiring bespoke modifiers", () => {
30+
render(<PanelHeader title="Git" status={<span>Modified</span>} meta={<span>2 files</span>} />);
31+
32+
const header = document.querySelector(".panel-header");
33+
const copy = header?.querySelector(".panel-header__copy");
34+
const status = header?.querySelector(".panel-header__status");
35+
36+
expect(header).toHaveClass("panel-header");
37+
expect(copy).not.toBeNull();
38+
expect(status).not.toBeNull();
39+
expect(within(copy as HTMLElement).getByText("Git")).toBeInTheDocument();
40+
expect(within(copy as HTMLElement).getByText("2 files")).toBeInTheDocument();
41+
expect(within(status as HTMLElement).getByText("Modified")).toBeInTheDocument();
42+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
43+
});
44+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ReactNode } from "react";
2+
3+
export interface PanelHeaderProps {
4+
title: string;
5+
meta?: ReactNode;
6+
status?: ReactNode;
7+
actions?: ReactNode;
8+
}
9+
10+
export function PanelHeader({ title, meta, status, actions }: PanelHeaderProps) {
11+
return (
12+
<div className="panel-header">
13+
<div className="panel-header__leading">
14+
<div className="panel-header__copy">
15+
<div className="panel-header__title-row">
16+
{status ? <div className="panel-header__status">{status}</div> : null}
17+
<div className="panel-header__title">{title}</div>
18+
</div>
19+
{meta ? <div className="panel-header__meta">{meta}</div> : null}
20+
</div>
21+
</div>
22+
{actions ? <div className="panel-header__actions">{actions}</div> : null}
23+
</div>
24+
);
25+
}

packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { useTranslation } from "../../../../lib/i18n";
1414
import { AgentPanes } from "../../../agent-panes";
1515
import { CodeEditorHost } from "../../../code-editor/views/shared/code-editor-host";
16+
import { PanelHeader } from "../../../shared/components/panel-header";
1617
import { TerminalPanel } from "../../../terminal-panel";
1718
import { TopBar } from "../../../topbar";
1819
import { useGitDiffViewerActions } from "../../actions/use-git-actions";
@@ -115,56 +116,60 @@ export const WorkspaceDesktopView: FC = () => {
115116
style={{ width: `${leftPanelWidth}px` }}
116117
>
117118
<div className="nav-panel workspace-sidebar-panel">
118-
<div className="workspace-sidebar-panel__header">
119-
<Tabs
120-
aria-label="Workspace sections"
121-
onValueChange={setSidebarTab}
122-
value={sidebarTab}
123-
>
124-
<TabList className="workspace-sidebar-panel__tabs">
125-
<Tab className="workspace-sidebar-panel__tab" value="files">
126-
<span>{t("file.title")}</span>
127-
</Tab>
128-
<Tab className="workspace-sidebar-panel__tab" value="git">
129-
<span>{t("label.git")}</span>
130-
</Tab>
131-
</TabList>
132-
</Tabs>
133-
134-
<div className="workspace-sidebar-panel__actions">
135-
{sidebarTab === "files" ? (
136-
<>
137-
<Tooltip content={t("file.new_file")}>
138-
<IconButton
139-
className="panel-toolbar-btn"
140-
aria-label={t("file.new_file")}
141-
icon={<ThemedIcon semantic="file.action.new" size={14} />}
142-
onClick={handleOpenFileCreate}
143-
size="sm"
144-
/>
145-
</Tooltip>
146-
<Tooltip content={t("file.new_folder")}>
147-
<IconButton
148-
className="panel-toolbar-btn"
149-
aria-label={t("file.new_folder")}
150-
icon={<ThemedIcon semantic="file.action.newFolder" size={14} />}
151-
onClick={handleOpenFolderCreate}
152-
size="sm"
153-
/>
154-
</Tooltip>
155-
<Tooltip content={t("file.collapse_all")}>
156-
<IconButton
157-
className="panel-toolbar-btn"
158-
aria-label={t("file.collapse_all")}
159-
icon={<ChevronsUp size={14} />}
160-
onClick={() => setFileTreeCollapseVersion((value) => value + 1)}
161-
size="sm"
162-
/>
163-
</Tooltip>
164-
</>
165-
) : null}
166-
</div>
167-
</div>
119+
<PanelHeader
120+
title={t("workspace.title")}
121+
meta={
122+
<Tabs
123+
aria-label="Workspace sections"
124+
onValueChange={setSidebarTab}
125+
value={sidebarTab}
126+
>
127+
<TabList className="workspace-sidebar-panel__tabs">
128+
<Tab className="workspace-sidebar-panel__tab" value="files">
129+
<span>{t("file.title")}</span>
130+
</Tab>
131+
<Tab className="workspace-sidebar-panel__tab" value="git">
132+
<span>{t("label.git")}</span>
133+
</Tab>
134+
</TabList>
135+
</Tabs>
136+
}
137+
actions={
138+
<div className="workspace-sidebar-panel__actions">
139+
{sidebarTab === "files" ? (
140+
<>
141+
<Tooltip content={t("file.new_file")}>
142+
<IconButton
143+
className="panel-toolbar-btn"
144+
aria-label={t("file.new_file")}
145+
icon={<ThemedIcon semantic="file.action.new" size={14} />}
146+
onClick={handleOpenFileCreate}
147+
size="sm"
148+
/>
149+
</Tooltip>
150+
<Tooltip content={t("file.new_folder")}>
151+
<IconButton
152+
className="panel-toolbar-btn"
153+
aria-label={t("file.new_folder")}
154+
icon={<ThemedIcon semantic="file.action.newFolder" size={14} />}
155+
onClick={handleOpenFolderCreate}
156+
size="sm"
157+
/>
158+
</Tooltip>
159+
<Tooltip content={t("file.collapse_all")}>
160+
<IconButton
161+
className="panel-toolbar-btn"
162+
aria-label={t("file.collapse_all")}
163+
icon={<ChevronsUp size={14} />}
164+
onClick={() => setFileTreeCollapseVersion((value) => value + 1)}
165+
size="sm"
166+
/>
167+
</Tooltip>
168+
</>
169+
) : null}
170+
</div>
171+
}
172+
/>
168173

169174
<div className="workspace-sidebar-panel__body">
170175
{sidebarTab === "files" ? (

0 commit comments

Comments
 (0)