@cueloop/client 0.1.0-alpha.74 → 0.1.0-alpha.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.regression.test.tsx +94 -0
  4. package/src/App.test.tsx +11 -8
  5. package/src/App.tsx +476 -220
  6. package/src/App.visual.test.tsx +57 -0
  7. package/src/App.workbench.test.tsx +41 -22
  8. package/src/app-screens.tsx +168 -123
  9. package/src/components/AppShell.brand.test.tsx +1 -1
  10. package/src/components/AppShell.tsx +14 -1
  11. package/src/components/ChangesColumn.tsx +40 -12
  12. package/src/components/ChangesFileTree.test.tsx +61 -0
  13. package/src/components/ConfirmDialog.tsx +8 -1
  14. package/src/components/DiffContentView.tsx +7 -1
  15. package/src/components/DiscussionMarkerRail.tsx +2 -2
  16. package/src/components/EditorGrid.tsx +84 -26
  17. package/src/components/GridTabContent.tsx +10 -1
  18. package/src/components/PanelColumn.tsx +3 -0
  19. package/src/components/ProjectTreeView.test.tsx +39 -12
  20. package/src/components/ProjectTreeView.tsx +38 -10
  21. package/src/components/PromptDialog.test.tsx +61 -0
  22. package/src/components/PromptDialog.tsx +16 -3
  23. package/src/components/RootOverlay.stories.tsx +1 -0
  24. package/src/components/RootOverlay.tsx +19 -6
  25. package/src/components/SettingsDialog.stories.tsx +3 -3
  26. package/src/components/SettingsDialog.tsx +3 -4
  27. package/src/components/ThreadFooter.test.tsx +12 -7
  28. package/src/components/ThreadFooter.tsx +18 -4
  29. package/src/components/ThreadTree.tsx +79 -36
  30. package/src/components/ThreadView.slash.test.tsx +36 -0
  31. package/src/components/ThreadView.tsx +7 -1
  32. package/src/components/WelcomePlayground.tsx +2 -0
  33. package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
  34. package/src/components/menu-control.stories.tsx +35 -0
  35. package/src/components/menu-control.tsx +43 -0
  36. package/src/components/primitives/Dialog.test.tsx +37 -0
  37. package/src/components/primitives/Dialog.tsx +5 -0
  38. package/src/components/primitives/icons.ts +1 -1
  39. package/src/components/quick-actions-editor.stories.tsx +2 -0
  40. package/src/components/quick-actions-editor.test.tsx +35 -1
  41. package/src/components/quick-actions-editor.tsx +23 -22
  42. package/src/components/session-tree.test.ts +3 -3
  43. package/src/components/session-tree.ts +1 -1
  44. package/src/config.ts +1 -0
  45. package/src/diff-intraline.ts +26 -5
  46. package/src/perf/otlp-trace.test.ts +116 -0
  47. package/src/perf/otlp-trace.ts +130 -0
  48. package/src/perf/perf-report.ts +17 -0
  49. package/src/perf/perf-timings.test.ts +18 -0
  50. package/src/perf/perf-timings.ts +38 -0
  51. package/src/run.ts +22 -2
  52. package/src/thread-chords.test.ts +33 -1
  53. package/src/thread-chords.ts +114 -29
  54. package/src/thread-controller.ts +4 -3
  55. package/src/use-annotation-surface.tsx +22 -0
  56. package/src/use-frame-measure.ts +4 -1
  57. package/src/use-settings-dialog.tsx +9 -7
  58. package/src/visual/contrast.test.ts +35 -0
  59. package/src/visual/contrast.ts +33 -0
@@ -0,0 +1,57 @@
1
+ /** Scene visual regression: clicking a sidebar thread paints its title in the theme's accent, in both
2
+ * themes. Colours come from captureSpans, so a theme or click-feedback regression fails here. */
3
+
4
+ import { afterEach, beforeEach, expect, test } from "bun:test";
5
+ import React from "react";
6
+ import { App } from "./App";
7
+ import { clickText, isolateUserConfig, renderReadyApp, waitForText } from "./test-support";
8
+ import { themeForName } from "./theme-presets";
9
+ import { rgbColorFromHex } from "./visual/contrast";
10
+ import { createTestReviewHome, type TestReviewHome } from "../../../test/helpers/review-home";
11
+
12
+ let reviewHome: TestReviewHome;
13
+ let restoreUserConfig: () => void;
14
+
15
+ beforeEach(() => {
16
+ reviewHome = createTestReviewHome();
17
+ restoreUserConfig = isolateUserConfig(reviewHome.home);
18
+ reviewHome.createPlanSession("# First review\n", "First review");
19
+ reviewHome.createPlanSession("# Second review\n", "Second review");
20
+ });
21
+ afterEach(() => {
22
+ restoreUserConfig();
23
+ reviewHome.cleanup();
24
+ });
25
+
26
+ /** Whether any span containing `needle` is painted in `color` (the thread header carries the title dimly). */
27
+ function hasSpanColor(
28
+ setup: Awaited<ReturnType<typeof renderReadyApp>>,
29
+ needle: string,
30
+ color: readonly [number, number, number],
31
+ ): boolean {
32
+ for (const line of setup.captureSpans().lines) {
33
+ for (const span of line.spans) {
34
+ if (!span.text.includes(needle) || !span.fg) continue;
35
+ const [red, green, blue] = span.fg.toInts();
36
+ if (red === color[0] && green === color[1] && blue === color[2]) return true;
37
+ }
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ for (const mode of ["dark", "light"] as const) {
44
+ test(`clicking a thread paints it in the ${mode} accent`, async () => {
45
+ const accent = rgbColorFromHex(themeForName("cueloop", mode).accent);
46
+ const setup = await renderReadyApp(<App home={reviewHome.home} appearance={mode} />, {
47
+ width: 120,
48
+ height: 32,
49
+ });
50
+ await waitForText(setup, "Second review");
51
+
52
+ await clickText(setup, "Second review");
53
+
54
+ expect(hasSpanColor(setup, "Second review", accent)).toBe(true);
55
+ setup.renderer.destroy();
56
+ });
57
+ }
@@ -1,8 +1,8 @@
1
1
  /** The four-pane workbench in the virtual terminal, mirroring the browser prototype's interactions:
2
2
  * a diff session lays out Threads | Thread | Changes editor | Project sidebar; the diff (±) and tree
3
3
  * toggles are mutually exclusive and switching to tree keeps the Changes editor open; a project-tree
4
- * file opens as a read-only contents tab; the split control offers four directions and Split Right makes
5
- * two editor groups; dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar;
4
+ * file opens as a read-only contents tab; the split control offers four directions and the arrow keys
5
+ * split; dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar;
6
6
  * the right-sidebar toggle collapses the region to a thin rail and reopens it; zoom hides the Thread pane
7
7
  * while the sidebars stay. */
8
8
 
@@ -152,7 +152,7 @@ describe("the four-pane workbench", () => {
152
152
  expect(frame.split("\n")[HEADER_ROW]!).toContain("README.md");
153
153
  });
154
154
 
155
- test("the split control offers four directions and Split Right makes two groups", async () => {
155
+ test("the split control offers four directions and arrow-right makes two groups", async () => {
156
156
  const setup = await renderApp();
157
157
 
158
158
  await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
@@ -164,17 +164,16 @@ describe("the four-pane workbench", () => {
164
164
  // the split control appears only on a file tab
165
165
  const split = locateText(setup, "split");
166
166
  await setup.mockMouse.click(split.column, split.row);
167
- await waitForText(setup, "Split Right");
167
+ await waitForText(setup, "right");
168
168
 
169
169
  const menu = setup.captureCharFrame();
170
- expect(menu).toContain("Split Left");
171
- expect(menu).toContain("Split Right");
172
- expect(menu).toContain("Split Up");
173
- expect(menu).toContain("Split Down");
174
-
175
- const right = locateText(setup, "Split Right");
176
- await setup.mockMouse.click(right.column, right.row);
177
- // two editor groups now ride the header, each with its own split control
170
+ expect(menu).toContain("left");
171
+ expect(menu).toContain("right");
172
+ expect(menu).toContain("up");
173
+ expect(menu).toContain("down");
174
+
175
+ // the arrow keys drive the popover: right splits the group into two
176
+ await pressKey(setup, "ARROW_RIGHT");
178
177
  await waitForState(setup, () => setup.captureCharFrame().split("split").length - 1 >= 2);
179
178
  expect((setup.captureCharFrame().match(/README\.md/g) ?? []).length).toBeGreaterThanOrEqual(2);
180
179
  });
@@ -239,11 +238,11 @@ describe("the four-pane workbench", () => {
239
238
 
240
239
  const zoom = locateText(setup, NERD.zoom);
241
240
  await setup.mockMouse.click(zoom.column, zoom.row);
242
- await waitForState(setup, () => !setup.captureCharFrame().includes("review the changes"));
241
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Select a thread"));
243
242
 
244
243
  const frame = setup.captureCharFrame();
245
244
  // the Thread pane's prompt is gone...
246
- expect(frame).not.toContain("review the changes");
245
+ expect(frame).not.toContain("Select a thread");
247
246
  // ...while the Changes editor and Project sidebar stay
248
247
  expect(frame).toContain("store.ts");
249
248
  expect(diffToggleColumn(setup)).toBeGreaterThan(0);
@@ -293,7 +292,7 @@ describe("the four-pane workbench", () => {
293
292
  // zoom the diff - the Thread pane hides and the footer rides the Changes pane
294
293
  const zoom = locateText(setup, NERD.zoom);
295
294
  await setup.mockMouse.click(zoom.column, zoom.row);
296
- await waitForState(setup, () => !setup.captureCharFrame().includes("review the changes"));
295
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Select a thread"));
297
296
 
298
297
  // switch to the plan thread: it has no Changes editor, so a stranded zoom would blank the middle
299
298
  const planRow = locateText(setup, "Zoomed Plan");
@@ -312,13 +311,13 @@ describe("the four-pane workbench", () => {
312
311
  // zoom the Changes editor - the Thread pane hides
313
312
  const zoom = locateText(setup, NERD.zoom);
314
313
  await setup.mockMouse.click(zoom.column, zoom.row);
315
- await waitForState(setup, () => !setup.captureCharFrame().includes("review the changes"));
314
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Select a thread"));
316
315
 
317
316
  // toggling the right sidebar off must not strand a blank screen: zoom exits, the Thread pane returns
318
317
  await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
319
- await waitForText(setup, "review the changes");
318
+ await waitForText(setup, "Select a thread");
320
319
  const frame = setup.captureCharFrame();
321
- expect(frame).toContain("review the changes");
320
+ expect(frame).toContain("Select a thread");
322
321
  // the right region is collapsed and the Changes editor is gone
323
322
  expect(frame).not.toContain("store.ts");
324
323
  expect(diffToggleColumn(setup)).toBe(-1);
@@ -452,8 +451,8 @@ describe("the bare-launch welcome shell", () => {
452
451
  />,
453
452
  { width: 180, height: 14 },
454
453
  );
455
- await waitForText(setup, "no threads");
456
- expect(setup.captureCharFrame()).toContain("no threads");
454
+ await waitForText(setup, "Select a thread");
455
+ expect(setup.captureCharFrame()).toContain("Select a thread");
457
456
  });
458
457
 
459
458
  test("closing the right region on the vanilla shell remembers it for the next launch", async () => {
@@ -507,7 +506,27 @@ describe("the bare-launch welcome shell", () => {
507
506
  expect(frame.split("\n")[HEADER_ROW]!).toContain("Welcome");
508
507
  expect(frame).toContain("Getting started");
509
508
  // the Thread pane shows its empty state until a thread is opened
510
- expect(frame).toContain("no threads");
509
+ expect(frame).toContain("Select a thread");
510
+ });
511
+
512
+ test("the Threads sidebar owns the keyboard until a pane is clicked, so nav never touches the editor", async () => {
513
+ const setup = await renderReadyApp(
514
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
515
+ { width: 160, height: 24 },
516
+ );
517
+ await waitForText(setup, "Getting started");
518
+
519
+ // focus starts on the Threads sidebar: a letter drives the inbox, it never drafts in the editor
520
+ await typeText(setup, "zzz");
521
+ expect(setup.captureCharFrame()).not.toContain("zzz");
522
+
523
+ // clicking a line in the Welcome editor focuses that pane; now typing composes there
524
+ const line = locateText(setup, "quick brown fox");
525
+ await setup.mockMouse.click(line.column, line.row);
526
+ await typeText(setup, "note");
527
+ await waitForText(setup, "● note");
528
+
529
+ setup.renderer.destroy();
511
530
  });
512
531
 
513
532
  test("typing in the welcome composer never reaches the inbox keys", async () => {
@@ -552,7 +571,7 @@ describe("the bare-launch welcome shell", () => {
552
571
  // the Threads sidebar and the Thread empty state remain - never a blank shell
553
572
  const frame = setup.captureCharFrame();
554
573
  expect(frame).toContain("cueloop");
555
- expect(frame).toContain("no threads");
574
+ expect(frame).toContain("Select a thread");
556
575
  });
557
576
 
558
577
  test("the changed-files and tree toggles switch navigator mode without collapsing", async () => {
@@ -21,8 +21,9 @@ import { SettingsDialog } from "./components/SettingsDialog";
21
21
  import { CompletionOverlay } from "./components/CompletionOverlay";
22
22
  import { ThreadTree } from "./components/ThreadTree";
23
23
  import { WelcomePlayground } from "./components/WelcomePlayground";
24
- import { AppShell, type ProjectPanelMode } from "./components/AppShell";
24
+ import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
25
25
  import { EditorGrid } from "./components/EditorGrid";
26
+ import { MenuControlProvider, type MenuControlApi } from "./components/menu-control";
26
27
  import { ProjectTreeView } from "./components/ProjectTreeView";
27
28
  import { ChangesFileTree } from "./components/ChangesColumn";
28
29
  import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
@@ -58,6 +59,7 @@ export function MenuChrome(props: {
58
59
  settingsNav: SettingsNav;
59
60
  onCategorySelect: (categoryId: string) => void;
60
61
  cycleSetting: (rowKey: string) => void;
62
+ onClose: () => void;
61
63
  }): React.ReactNode {
62
64
  const {
63
65
  menuDialog,
@@ -68,6 +70,7 @@ export function MenuChrome(props: {
68
70
  settingsNav,
69
71
  onCategorySelect,
70
72
  cycleSetting,
73
+ onClose,
71
74
  } = props;
72
75
 
73
76
  // the gear opens the settings dialog directly; Keybinds is a leaf in its tree nav
@@ -85,6 +88,7 @@ export function MenuChrome(props: {
85
88
  activeZone={settingsNav.zone}
86
89
  onCategorySelect={onCategorySelect}
87
90
  onRowActivate={(row) => cycleSetting(row.key)}
91
+ onClose={onClose}
88
92
  theme={theme}
89
93
  />
90
94
  );
@@ -99,6 +103,7 @@ function WelcomeProjectPanel({
99
103
  controller,
100
104
  onOpenChangedFile,
101
105
  onOpenProjectFile,
106
+ focused,
102
107
  theme,
103
108
  }: {
104
109
  mode: ProjectPanelMode;
@@ -107,6 +112,7 @@ function WelcomeProjectPanel({
107
112
  onOpenChangedFile: (path: string) => void;
108
113
  /** Project tree click -> open the file's read-only contents. */
109
114
  onOpenProjectFile: (path: string) => void;
115
+ focused?: boolean;
110
116
  theme: Theme;
111
117
  }): React.ReactNode {
112
118
  const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
@@ -129,13 +135,21 @@ function WelcomeProjectPanel({
129
135
  }, [controller]);
130
136
 
131
137
  if (mode === "changes") {
132
- return <ChangesFileTree files={changes} onSelectFile={onOpenChangedFile} theme={theme} />;
138
+ return (
139
+ <ChangesFileTree
140
+ files={changes}
141
+ onSelectFile={onOpenChangedFile}
142
+ focused={focused}
143
+ theme={theme}
144
+ />
145
+ );
133
146
  }
134
147
 
135
148
  return (
136
149
  <ProjectTreeView
137
150
  loadFiles={() => controller.repoFiles()}
138
151
  onSelectFile={onOpenProjectFile}
152
+ focused={focused}
139
153
  theme={theme}
140
154
  />
141
155
  );
@@ -150,6 +164,8 @@ function WelcomeProjectPanel({
150
164
  export function NoThreadShell(props: {
151
165
  rows: InboxRow[];
152
166
  inboxCursor: number;
167
+ /** Open a sidebar thread and move the cursor onto it, so the row shows its selected backdrop at once. */
168
+ onOpenThread: (id: string) => void;
153
169
  mode: Mode;
154
170
  theme: Theme;
155
171
  controller: ReviewController;
@@ -159,6 +175,10 @@ export function NoThreadShell(props: {
159
175
  /** Shared with the thread view, so picking a thread preserves the sidebar. */
160
176
  sidebarOpen: boolean;
161
177
  onToggleSidebar: () => void;
178
+ focusedPane: FocusPane;
179
+ onFocusPane: (pane: FocusPane) => void;
180
+ /** App owns the one open-menu id, so the bare shell's menus share App's modal keyboard handling. */
181
+ menuControl: MenuControlApi;
162
182
  pinnedIds: ReadonlySet<string>;
163
183
  onPin: (id: string) => void;
164
184
  onRename: (id: string, title: string) => void;
@@ -172,6 +192,7 @@ export function NoThreadShell(props: {
172
192
  const {
173
193
  rows,
174
194
  inboxCursor,
195
+ onOpenThread,
175
196
  mode,
176
197
  theme,
177
198
  controller,
@@ -180,6 +201,9 @@ export function NoThreadShell(props: {
180
201
  onOpenMenu,
181
202
  sidebarOpen,
182
203
  onToggleSidebar,
204
+ focusedPane,
205
+ onFocusPane,
206
+ menuControl,
183
207
  pinnedIds,
184
208
  onPin,
185
209
  onRename,
@@ -217,7 +241,7 @@ export function NoThreadShell(props: {
217
241
  observer: false,
218
242
  commentsEnabled: true,
219
243
  resolved: false,
220
- suspended: false,
244
+ suspended: focusedPane !== "changes" || menuControl.openMenuId !== null,
221
245
  onComposingChange: onWelcomeComposingChange,
222
246
  onObserverBlocked: () => {},
223
247
  onCursorChange: () => {},
@@ -238,133 +262,146 @@ export function NoThreadShell(props: {
238
262
 
239
263
  return (
240
264
  <ThemeProvider theme={theme}>
241
- <AppShell
242
- sidebarOpen={sidebarOpen}
243
- onToggleSidebar={onToggleSidebar}
244
- onOpenMenu={onOpenMenu}
245
- threadsPanel={
246
- <ScrollArea>
247
- <ThreadTree
248
- rows={rows}
249
- cursor={inboxCursor}
250
- pinnedIds={pinnedIds}
251
- width={30}
252
- onSelect={(id) => controller.open(id)}
253
- onRequestDelete={(id, title) =>
254
- setMode({ type: "confirmDelete", sessionId: id, title })
265
+ <MenuControlProvider value={menuControl}>
266
+ <AppShell
267
+ sidebarOpen={sidebarOpen}
268
+ onToggleSidebar={onToggleSidebar}
269
+ onOpenMenu={onOpenMenu}
270
+ onFocusPane={onFocusPane}
271
+ threadsPanel={
272
+ <ScrollArea>
273
+ <ThreadTree
274
+ rows={rows}
275
+ cursor={inboxCursor}
276
+ focused={focusedPane === "threads"}
277
+ pinnedIds={pinnedIds}
278
+ width={30}
279
+ onSelect={onOpenThread}
280
+ onRequestDelete={(id, title) =>
281
+ setMode({ type: "confirmDelete", sessionId: id, title })
282
+ }
283
+ onPin={onPin}
284
+ onRename={onRename}
285
+ theme={theme}
286
+ />
287
+ </ScrollArea>
288
+ }
289
+ threadTitle=""
290
+ threadPanel={
291
+ <box
292
+ style={{
293
+ flexGrow: 1,
294
+ flexDirection: "column",
295
+ alignItems: "center",
296
+ justifyContent: "center",
297
+ }}
298
+ >
299
+ <text fg={theme.textDim}>Select a thread</text>
300
+ </box>
301
+ }
302
+ changesOpen={workbench.changesOpen}
303
+ projectOpen={workbench.projectOpen}
304
+ onToggleChanges={workbench.toggleChanges}
305
+ onToggleProject={workbench.toggleProject}
306
+ onToggleRight={workbench.toggleRight}
307
+ projectMode={workbench.projectMode}
308
+ zoomHideThread={workbench.zoomed}
309
+ changesPanel={
310
+ <EditorGrid
311
+ tree={workbench.grid}
312
+ focusedGroupId={workbench.activeGroup}
313
+ onFocusGroup={workbench.focusGroup}
314
+ onActivateTab={workbench.activate}
315
+ onCloseTab={workbench.close}
316
+ onSplit={workbench.split}
317
+ onZoom={workbench.toggleZoom}
318
+ zoomed={workbench.zoomed}
319
+ renderTab={(tab) =>
320
+ tab.kind === "welcome" ? (
321
+ <WelcomePlayground
322
+ version={CLIENT_VERSION}
323
+ quickActions={quickActions}
324
+ onComposingChange={onWelcomeComposingChange}
325
+ suspended={focusedPane !== "changes" || menuControl.openMenuId !== null}
326
+ theme={theme}
327
+ />
328
+ ) : tab.fileView === "contents" ? (
329
+ <BareWorkbenchFileView
330
+ path={tab.path ?? ""}
331
+ controller={controller}
332
+ quickActions={quickActions}
333
+ onComposingChange={onWelcomeComposingChange}
334
+ onExit={() => {}}
335
+ theme={theme}
336
+ />
337
+ ) : (
338
+ <GridTabContent
339
+ tab={tab}
340
+ rows={controller.rows()}
341
+ surface={bareSurface}
342
+ rejectedRows={EMPTY_ROWS}
343
+ dimmed={false}
344
+ readFile={(path) => controller.repoReadFile(path)}
345
+ onAddFileComment={(path, anchor, body) =>
346
+ void controller.commentOnWorkbench(
347
+ anchor,
348
+ { kind: "file", path, rev: "worktree" },
349
+ body,
350
+ )
351
+ }
352
+ theme={theme}
353
+ />
354
+ )
255
355
  }
256
- onPin={onPin}
257
- onRename={onRename}
258
356
  theme={theme}
259
357
  />
260
- </ScrollArea>
261
- }
262
- threadTitle=""
263
- threadPanel={
264
- <box
265
- style={{
266
- flexGrow: 1,
267
- flexDirection: "column",
268
- alignItems: "center",
269
- justifyContent: "center",
270
- }}
271
- >
272
- <text fg={theme.textDim}>{rows.length === 0 ? "no threads" : "Select a thread"}</text>
273
- </box>
274
- }
275
- changesOpen={workbench.changesOpen}
276
- projectOpen={workbench.projectOpen}
277
- onToggleChanges={workbench.toggleChanges}
278
- onToggleProject={workbench.toggleProject}
279
- onToggleRight={workbench.toggleRight}
280
- projectMode={workbench.projectMode}
281
- zoomHideThread={workbench.zoomed}
282
- changesPanel={
283
- <EditorGrid
284
- tree={workbench.grid}
285
- focusedGroupId={workbench.activeGroup}
286
- onFocusGroup={workbench.focusGroup}
287
- onActivateTab={workbench.activate}
288
- onCloseTab={workbench.close}
289
- onSplit={workbench.split}
290
- onZoom={workbench.toggleZoom}
291
- zoomed={workbench.zoomed}
292
- renderTab={(tab) =>
293
- tab.kind === "welcome" ? (
294
- <WelcomePlayground
295
- version={CLIENT_VERSION}
296
- quickActions={quickActions}
297
- onComposingChange={onWelcomeComposingChange}
298
- theme={theme}
299
- />
300
- ) : tab.fileView === "contents" ? (
301
- <BareWorkbenchFileView
302
- path={tab.path ?? ""}
303
- controller={controller}
304
- quickActions={quickActions}
305
- onComposingChange={onWelcomeComposingChange}
306
- onExit={() => {}}
307
- theme={theme}
308
- />
309
- ) : (
310
- <GridTabContent
311
- tab={tab}
312
- rows={controller.rows()}
313
- surface={bareSurface}
314
- rejectedRows={EMPTY_ROWS}
315
- dimmed={false}
316
- readFile={(path) => controller.repoReadFile(path)}
317
- onAddFileComment={(path, anchor, body) =>
318
- void controller.commentOnWorkbench(
319
- anchor,
320
- { kind: "file", path, rev: "worktree" },
321
- body,
322
- )
323
- }
324
- theme={theme}
325
- />
326
- )
327
- }
328
- theme={theme}
329
- />
330
- }
331
- projectPanel={
332
- <WelcomeProjectPanel
333
- mode={workbench.projectMode}
334
- controller={controller}
335
- onOpenChangedFile={openChangedFile}
336
- onOpenProjectFile={openProjectFile}
337
- theme={theme}
338
- />
339
- }
340
- theme={theme}
341
- >
342
- {menuChrome}
343
- <ConfirmDialog
344
- isOpen={confirming !== null}
345
- title=" Delete plan "
346
- message={
347
- confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""
348
358
  }
349
- onConfirm={() => {
350
- if (confirming) controller.deleteSession(confirming.sessionId);
351
- setMode({ type: "normal" });
352
- }}
353
- onCancel={() => setMode({ type: "normal" })}
359
+ projectPanel={
360
+ <WelcomeProjectPanel
361
+ mode={workbench.projectMode}
362
+ controller={controller}
363
+ onOpenChangedFile={openChangedFile}
364
+ onOpenProjectFile={openProjectFile}
365
+ focused={focusedPane === "project"}
366
+ theme={theme}
367
+ />
368
+ }
354
369
  theme={theme}
355
- />
356
- {mode.type === "renameThread" ? (
357
- <PromptDialog
358
- isOpen
359
- title=" rename thread "
360
- label="new title for this thread:"
361
- value={mode.text}
362
- placeholder="a short title"
363
- onInput={(text) => setMode({ ...mode, text })}
370
+ >
371
+ {menuChrome}
372
+ <ConfirmDialog
373
+ isOpen={confirming !== null}
374
+ title=" Delete plan "
375
+ message={
376
+ confirming
377
+ ? `Delete "${confirming.title}"? This removes the plan and its review.`
378
+ : ""
379
+ }
380
+ onConfirm={() => {
381
+ if (confirming) controller.deleteSession(confirming.sessionId);
382
+ setMode({ type: "normal" });
383
+ }}
384
+ onCancel={() => setMode({ type: "normal" })}
364
385
  theme={theme}
365
386
  />
366
- ) : null}
367
- </AppShell>
387
+ {mode.type === "renameThread" ? (
388
+ <PromptDialog
389
+ isOpen
390
+ title=" rename thread "
391
+ label="new title for this thread:"
392
+ value={mode.text}
393
+ placeholder="a short title"
394
+ onInput={(text) => setMode({ ...mode, text })}
395
+ onSave={() => {
396
+ controller.renameSession(mode.sessionId, mode.text.trim());
397
+ setMode({ type: "normal" });
398
+ }}
399
+ onCancel={() => setMode({ type: "normal" })}
400
+ theme={theme}
401
+ />
402
+ ) : null}
403
+ </AppShell>
404
+ </MenuControlProvider>
368
405
  </ThemeProvider>
369
406
  );
370
407
  }
@@ -464,6 +501,8 @@ export function TrailingOverlays(props: {
464
501
  value={mode.text}
465
502
  placeholder="their name"
466
503
  onInput={(text) => setMode({ ...mode, text })}
504
+ onSave={() => dispatch({ type: "confirmDialog" })}
505
+ onCancel={() => setMode({ type: "normal" })}
467
506
  theme={theme}
468
507
  />
469
508
  ) : null}
@@ -475,6 +514,8 @@ export function TrailingOverlays(props: {
475
514
  value={mode.text}
476
515
  placeholder="a short title"
477
516
  onInput={(text) => setMode({ ...mode, text })}
517
+ onSave={() => dispatch({ type: "confirmDialog" })}
518
+ onCancel={() => setMode({ type: "normal" })}
478
519
  theme={theme}
479
520
  />
480
521
  ) : null}
@@ -486,6 +527,8 @@ export function TrailingOverlays(props: {
486
527
  value={mode.text}
487
528
  placeholder="your name"
488
529
  onInput={(text) => setMode({ ...mode, text })}
530
+ onSave={() => dispatch({ type: "confirmDialog" })}
531
+ onCancel={() => setMode({ type: "normal" })}
489
532
  theme={theme}
490
533
  />
491
534
  ) : null}
@@ -497,6 +540,8 @@ export function TrailingOverlays(props: {
497
540
  value={mode.text}
498
541
  placeholder={TREE_PROMPTS[mode.ask].placeholder}
499
542
  onInput={(text) => setMode({ ...mode, text })}
543
+ onSave={() => dispatch({ type: "confirmDialog" })}
544
+ onCancel={() => setMode({ type: "normal" })}
500
545
  theme={theme}
501
546
  />
502
547
  ) : null}
@@ -22,7 +22,7 @@ async function brandFrame(): Promise<string[]> {
22
22
  onOpenMenu={() => {}}
23
23
  threadsPanel={<text>threads</text>}
24
24
  threadTitle={LONG_TITLE}
25
- threadPanel={<text>review the changes on the right</text>}
25
+ threadPanel={<text>Select a thread</text>}
26
26
  changesOpen
27
27
  projectOpen
28
28
  onToggleChanges={() => {}}
@@ -69,8 +69,11 @@ export interface AppShellProps {
69
69
  theme?: Theme;
70
70
  threadsWidth?: number;
71
71
  projectWidth?: number;
72
+ onFocusPane?: (pane: FocusPane) => void;
72
73
  }
73
74
 
75
+ export type FocusPane = "threads" | "thread" | "changes" | "project";
76
+
74
77
  /** The right region's reserved width: the open project pane, nothing when the region is closed, else the collapsed rail. */
75
78
  function rightRegionColumns(
76
79
  projectOpen: boolean,
@@ -122,6 +125,7 @@ export function AppShell({
122
125
  theme,
123
126
  threadsWidth = 30,
124
127
  projectWidth = 32,
128
+ onFocusPane,
125
129
  }: AppShellProps): React.ReactNode {
126
130
  const tokens = theme ?? DARK;
127
131
  const { width: terminalWidth } = useTerminalDimensions();
@@ -214,7 +218,13 @@ export function AppShell({
214
218
  <TooltipProvider theme={tokens}>
215
219
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
216
220
  {sidebarOpen ? (
217
- <PanelColumn width={threadsWidth} border="right" header={brandChrome} theme={tokens}>
221
+ <PanelColumn
222
+ width={threadsWidth}
223
+ border="right"
224
+ header={brandChrome}
225
+ onFocus={() => onFocusPane?.("threads")}
226
+ theme={tokens}
227
+ >
218
228
  {threadsPanel}
219
229
  </PanelColumn>
220
230
  ) : null}
@@ -237,6 +247,7 @@ export function AppShell({
237
247
  reopenRightControl,
238
248
  rightRegionClosed,
239
249
  )}
250
+ onFocus={() => onFocusPane?.("thread")}
240
251
  theme={tokens}
241
252
  >
242
253
  {threadPanel}
@@ -244,6 +255,7 @@ export function AppShell({
244
255
  ) : null}
245
256
  {changesOpen ? (
246
257
  <box
258
+ onMouseDown={() => onFocusPane?.("changes")}
247
259
  style={{
248
260
  flexDirection: "column",
249
261
  flexGrow: 1,
@@ -266,6 +278,7 @@ export function AppShell({
266
278
  border="left"
267
279
  header={null}
268
280
  headerRight={projectToggles}
281
+ onFocus={() => onFocusPane?.("project")}
269
282
  theme={tokens}
270
283
  >
271
284
  {projectPanel}