Skip to content

Commit fdc2367

Browse files
committed
feat: make workspace files panel collapsible
Signed-off-by: sallyom <[email protected]>
1 parent ca2410a commit fdc2367

6 files changed

Lines changed: 197 additions & 13 deletions

File tree

ui/src/styles/chat/sidebar.css

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
overflow: hidden;
99
}
1010

11+
.chat-workbench--workspace-collapsed {
12+
grid-template-columns: minmax(0, 1fr) 52px;
13+
}
14+
1115
.chat-split-container {
1216
display: flex;
1317
gap: 0;
@@ -45,6 +49,12 @@
4549
background: color-mix(in srgb, var(--panel) 84%, transparent);
4650
}
4751

52+
.chat-workspace-rail--collapsed {
53+
align-items: center;
54+
gap: 10px;
55+
padding: 12px 8px;
56+
}
57+
4858
.chat-workspace-rail__header {
4959
display: flex;
5060
align-items: center;
@@ -75,14 +85,27 @@
7585
line-height: 1.2;
7686
}
7787

78-
.chat-workspace-rail__refresh {
88+
.chat-workspace-rail__actions {
89+
display: inline-flex;
90+
align-items: center;
91+
gap: 6px;
92+
}
93+
94+
.chat-workspace-rail__refresh,
95+
.chat-workspace-rail__collapse-toggle {
7996
width: 32px;
8097
min-width: 32px;
8198
height: 32px;
8299
padding: 0;
83100
}
84101

102+
.chat-workspace-rail__collapse-toggle {
103+
flex: 0 0 auto;
104+
box-shadow: none;
105+
}
106+
85107
.chat-workspace-rail__refresh svg,
108+
.chat-workspace-rail__collapse-toggle svg,
86109
.chat-workspace-rail__file-icon svg {
87110
width: 15px;
88111
height: 15px;
@@ -93,6 +116,25 @@
93116
stroke-linejoin: round;
94117
}
95118

119+
.chat-workspace-rail__collapsed-icon {
120+
color: var(--muted);
121+
display: inline-flex;
122+
align-items: center;
123+
justify-content: center;
124+
width: 32px;
125+
height: 32px;
126+
}
127+
128+
.chat-workspace-rail__collapsed-icon svg {
129+
width: 18px;
130+
height: 18px;
131+
fill: none;
132+
stroke: currentColor;
133+
stroke-width: 1.5px;
134+
stroke-linecap: round;
135+
stroke-linejoin: round;
136+
}
137+
96138
.chat-workspace-rail__path {
97139
padding: 9px 12px;
98140
border-bottom: 1px solid color-mix(in srgb, var(--border) 42%, transparent);

ui/src/ui/app-render.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@ const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notif
680680
type ChatWorkspaceFilesState = {
681681
activeName: string | null;
682682
agentId: string;
683+
collapsed: boolean;
683684
error: string | null;
684685
list: AgentsFilesListResult | null;
685686
loading: boolean;
@@ -700,6 +701,7 @@ function getChatWorkspaceFilesState(state: AppViewState, agentId: string): ChatW
700701
const next = {
701702
activeName: null,
702703
agentId,
704+
collapsed: false,
703705
error: null,
704706
list: null,
705707
loading: false,
@@ -2098,6 +2100,7 @@ export function renderApp(state: AppViewState) {
20982100
};
20992101
if (
21002102
isChat &&
2103+
!chatWorkspaceFiles.collapsed &&
21012104
state.connected &&
21022105
state.agentsList &&
21032106
!chatWorkspaceFiles.loading &&
@@ -2106,6 +2109,13 @@ export function renderApp(state: AppViewState) {
21062109
) {
21072110
loadChatWorkspaceFiles();
21082111
}
2112+
const toggleChatWorkspaceFilesCollapsed = () => {
2113+
chatWorkspaceFiles.collapsed = !chatWorkspaceFiles.collapsed;
2114+
if (!chatWorkspaceFiles.collapsed && chatWorkspaceFiles.list?.agentId !== chatAgentId) {
2115+
loadChatWorkspaceFiles();
2116+
}
2117+
requestHostUpdate?.();
2118+
};
21092119
const refreshChatWorkspaceFiles = () => {
21102120
loadChatWorkspaceFiles({ force: true });
21112121
};
@@ -3534,6 +3544,7 @@ export function renderApp(state: AppViewState) {
35343544
sessions: state.sessionsResult,
35353545
composerControls: renderGuardedChatControls(state),
35363546
workspaceFiles: {
3547+
collapsed: chatWorkspaceFiles.collapsed,
35373548
agentId: chatAgentId,
35383549
list:
35393550
chatWorkspaceFiles.list?.agentId === chatAgentId
@@ -3542,6 +3553,7 @@ export function renderApp(state: AppViewState) {
35423553
loading: chatWorkspaceFiles.loading,
35433554
error: chatWorkspaceFiles.error,
35443555
activeName: chatWorkspaceFiles.activeName,
3556+
onToggleCollapsed: toggleChatWorkspaceFilesCollapsed,
35453557
onRefresh: refreshChatWorkspaceFiles,
35463558
onOpenFile: openChatWorkspaceFile,
35473559
},

ui/src/ui/e2e/chat-flow.e2e.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,51 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
221221
}
222222
});
223223

224+
it("collapses the workspace files panel from its header control", async () => {
225+
const context = await browser.newContext({
226+
locale: "en-US",
227+
serviceWorkers: "block",
228+
viewport: { height: 900, width: 1280 },
229+
});
230+
const page = await context.newPage();
231+
const gateway = await installMockGateway(page, {
232+
methodResponses: {
233+
"agents.files.list": {
234+
agentId: "main",
235+
files: [{ name: "AGENTS.md", path: "/workspace/AGENTS.md", size: 2048 }],
236+
workspace: "/workspace",
237+
},
238+
},
239+
});
240+
241+
try {
242+
await page.goto(`${server.baseUrl}chat`);
243+
await page.getByRole("button", { name: "Collapse workspace files" }).waitFor({
244+
timeout: 10_000,
245+
});
246+
await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 });
247+
expect(await gateway.getRequests("agents.files.list")).toHaveLength(1);
248+
249+
await page.getByRole("button", { name: "Collapse workspace files" }).click();
250+
await page.getByRole("button", { name: "Expand workspace files" }).waitFor({
251+
timeout: 10_000,
252+
});
253+
expect(await page.locator(".chat-workspace-rail__file").count()).toBe(0);
254+
expect(await page.locator(".chat-workspace-rail__collapsed-icon svg").count()).toBe(1);
255+
256+
await page.getByRole("button", { name: "Expand workspace files" }).click();
257+
await page.getByRole("button", { name: "Collapse workspace files" }).waitFor({
258+
timeout: 10_000,
259+
});
260+
await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 });
261+
262+
await page.setViewportSize({ height: 900, width: 1000 });
263+
expect(await page.locator(".chat-workspace-rail").isHidden()).toBe(true);
264+
} finally {
265+
await context.close();
266+
}
267+
});
268+
224269
it("renders stable markdown during a streaming chat turn and finalizes the tail", async () => {
225270
const context = await browser.newContext({
226271
locale: "en-US",

ui/src/ui/icons.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,13 @@ export const icons = {
496496
<path d="M10 10l-3 2 3 2" stroke-linecap="round" stroke-linejoin="round" />
497497
</svg>
498498
`,
499+
panelRightClose: html`
500+
<svg viewBox="0 0 24 24">
501+
<rect x="3" y="3" width="18" height="18" rx="2" />
502+
<path d="M15 3v18" stroke-linecap="round" />
503+
<path d="M8 10l3 2-3 2" stroke-linecap="round" stroke-linejoin="round" />
504+
</svg>
505+
`,
499506
maximize: html`
500507
<svg viewBox="0 0 24 24">
501508
<polyline points="15 3 21 3 21 9" />

ui/src/ui/views/chat.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -875,12 +875,14 @@ describe("chat goal status", () => {
875875
});
876876

877877
describe("chat composer workbench", () => {
878-
it("renders session controls in the composer and workspace files in the rail", () => {
878+
it("renders session controls in the composer and workspace files in the expanded rail", () => {
879+
const onToggleCollapsed = vi.fn();
879880
const onRefresh = vi.fn();
880881
const onOpenFile = vi.fn();
881882
const container = renderChatView({
882883
composerControls: html`<button class="test-composer-control">Model</button>`,
883884
workspaceFiles: {
885+
collapsed: false,
884886
agentId: "main",
885887
list: {
886888
agentId: "main",
@@ -897,6 +899,7 @@ describe("chat composer workbench", () => {
897899
loading: false,
898900
error: null,
899901
activeName: "AGENTS.md",
902+
onToggleCollapsed,
900903
onRefresh,
901904
onOpenFile,
902905
},
@@ -913,8 +916,41 @@ describe("chat composer workbench", () => {
913916
expect(file?.textContent).toContain("2 KB");
914917

915918
file?.click();
919+
container
920+
.querySelector<HTMLButtonElement>('button[aria-label="Collapse workspace files"]')
921+
?.click();
916922

917923
expect(onOpenFile).toHaveBeenCalledWith("AGENTS.md");
924+
expect(onToggleCollapsed).toHaveBeenCalledTimes(1);
925+
expect(container.querySelector('button[aria-label="Workspace files"]')).toBeNull();
926+
});
927+
928+
it("keeps the workspace files rail reachable from the collapsed strip", () => {
929+
const onToggleCollapsed = vi.fn();
930+
const container = renderChatView({
931+
workspaceFiles: {
932+
collapsed: true,
933+
agentId: "main",
934+
list: null,
935+
loading: false,
936+
error: null,
937+
activeName: null,
938+
onToggleCollapsed,
939+
onRefresh: () => undefined,
940+
onOpenFile: () => undefined,
941+
},
942+
});
943+
944+
expect(container.querySelector(".chat-workspace-rail__list")).toBeNull();
945+
expect(container.querySelector(".chat-workspace-rail__collapsed-icon")).not.toBeNull();
946+
const toggle = container.querySelector<HTMLButtonElement>(
947+
'button[aria-label="Expand workspace files"]',
948+
);
949+
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
950+
951+
toggle?.click();
952+
953+
expect(onToggleCollapsed).toHaveBeenCalledTimes(1);
918954
});
919955

920956
it("keeps the secondary New session and Export controls suppressed in the composer", () => {

ui/src/ui/views/chat.ts

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,13 @@ export type ChatProps = {
199199
basePath?: string;
200200
composerControls?: TemplateResult | typeof nothing | ReturnType<typeof guard>;
201201
workspaceFiles?: {
202+
collapsed: boolean;
202203
agentId: string;
203204
list: AgentsFilesListResult | null;
204205
loading: boolean;
205206
error: string | null;
206207
activeName: string | null;
208+
onToggleCollapsed: () => void;
207209
onRefresh: () => void;
208210
onOpenFile: (name: string) => void;
209211
};
@@ -1028,6 +1030,28 @@ function renderWorkspaceFileRail(
10281030
if (!workspaceFiles) {
10291031
return nothing;
10301032
}
1033+
if (workspaceFiles.collapsed) {
1034+
return html`
1035+
<aside
1036+
class="chat-workspace-rail chat-workspace-rail--collapsed"
1037+
aria-label="Workspace files"
1038+
>
1039+
<button
1040+
type="button"
1041+
class="nav-collapse-toggle chat-workspace-rail__collapse-toggle"
1042+
title="Expand workspace files"
1043+
aria-label="Expand workspace files"
1044+
aria-expanded="false"
1045+
@click=${workspaceFiles.onToggleCollapsed}
1046+
>
1047+
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.panelRightOpen}</span>
1048+
</button>
1049+
<span class="chat-workspace-rail__collapsed-icon" aria-hidden="true"
1050+
>${icons.fileText}</span
1051+
>
1052+
</aside>
1053+
`;
1054+
}
10311055
const files = workspaceFiles.list?.files ?? [];
10321056
return html`
10331057
<aside class="chat-workspace-rail" aria-label="Workspace files">
@@ -1036,16 +1060,30 @@ function renderWorkspaceFileRail(
10361060
<span class="chat-workspace-rail__eyebrow">Workspace</span>
10371061
<strong>Files</strong>
10381062
</div>
1039-
<button
1040-
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
1041-
type="button"
1042-
title="Refresh files"
1043-
aria-label="Refresh files"
1044-
?disabled=${workspaceFiles.loading}
1045-
@click=${workspaceFiles.onRefresh}
1046-
>
1047-
${icons.refresh}
1048-
</button>
1063+
<div class="chat-workspace-rail__actions">
1064+
<button
1065+
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
1066+
type="button"
1067+
title="Refresh files"
1068+
aria-label="Refresh files"
1069+
?disabled=${workspaceFiles.loading}
1070+
@click=${workspaceFiles.onRefresh}
1071+
>
1072+
${icons.refresh}
1073+
</button>
1074+
<button
1075+
type="button"
1076+
class="nav-collapse-toggle chat-workspace-rail__collapse-toggle"
1077+
title="Collapse workspace files"
1078+
aria-label="Collapse workspace files"
1079+
aria-expanded="true"
1080+
@click=${workspaceFiles.onToggleCollapsed}
1081+
>
1082+
<span class="nav-collapse-toggle__icon" aria-hidden="true"
1083+
>${icons.panelRightClose}</span
1084+
>
1085+
</button>
1086+
</div>
10491087
</div>
10501088
${workspaceFiles.list?.workspace
10511089
? html`<div class="chat-workspace-rail__path" title=${workspaceFiles.list.workspace}>
@@ -2058,7 +2096,11 @@ export function renderChat(props: ChatProps) {
20582096
: nothing}
20592097
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
20602098
2061-
<div class="chat-workbench">
2099+
<div
2100+
class="chat-workbench ${props.workspaceFiles?.collapsed
2101+
? "chat-workbench--workspace-collapsed"
2102+
: ""}"
2103+
>
20622104
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
20632105
<div
20642106
class="chat-main"

0 commit comments

Comments
 (0)