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

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 (40) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +35 -170
  3. package/src/App.workbench.test.tsx +103 -2
  4. package/src/app-screens.tsx +84 -10
  5. package/src/components/AppShell.brand.test.tsx +58 -0
  6. package/src/components/AppShell.tsx +32 -6
  7. package/src/components/BareWorkbenchFileView.tsx +1 -1
  8. package/src/components/ConfirmCard.tsx +0 -1
  9. package/src/components/GridTabContent.stories.tsx +59 -0
  10. package/src/components/GridTabContent.tsx +165 -0
  11. package/src/components/SettingsDialog.tsx +4 -13
  12. package/src/components/ThreadTree.tsx +6 -18
  13. package/src/components/__snapshots__/stories.test.tsx.snap +112 -68
  14. package/src/components/primitives/Card.tsx +1 -1
  15. package/src/components/primitives/frame.test.ts +5 -5
  16. package/src/components/primitives/frame.ts +5 -7
  17. package/src/components/quick-actions-editor.stories.tsx +2 -0
  18. package/src/components/quick-actions-editor.test.tsx +36 -2
  19. package/src/components/quick-actions-editor.tsx +104 -29
  20. package/src/components/truncate-title.test.ts +31 -0
  21. package/src/components/truncate-title.ts +15 -0
  22. package/src/config.test.ts +36 -0
  23. package/src/config.ts +38 -8
  24. package/src/index.ts +2 -0
  25. package/src/intent-dispatch.test.ts +1 -0
  26. package/src/launch-layout.test.ts +52 -0
  27. package/src/launch-layout.ts +42 -0
  28. package/src/run.ts +10 -0
  29. package/src/serve.ts +31 -0
  30. package/src/thread-controller.live-diff.test.ts +119 -0
  31. package/src/thread-controller.ts +88 -27
  32. package/src/use-changes-workbench.ts +41 -4
  33. package/src/use-remember-layout.ts +31 -0
  34. package/src/use-settings-dialog.tsx +11 -2
  35. package/src/view-diff.test.ts +73 -2
  36. package/src/view-diff.ts +38 -0
  37. package/src/workbench-snapshot.test.ts +81 -0
  38. package/src/workbench-snapshot.ts +29 -0
  39. package/src/components/fade-title.test.ts +0 -49
  40. package/src/components/fade-title.ts +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.72",
3
+ "version": "0.1.0-alpha.74",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.72",
31
- "@cueloop/extension-api": "0.1.0-alpha.72",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.72",
33
- "@cueloop/schema": "0.1.0-alpha.72",
30
+ "@cueloop/daemon": "0.1.0-alpha.74",
31
+ "@cueloop/extension-api": "0.1.0-alpha.74",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.74",
33
+ "@cueloop/schema": "0.1.0-alpha.74",
34
34
  "@opentui/core": "^0.5.8",
35
35
  "@opentui/keymap": "0.5.8",
36
36
  "@opentui/react": "^0.5.8",
package/src/App.tsx CHANGED
@@ -9,7 +9,6 @@ import React, {
9
9
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
10
10
  import type { Clock } from "@opentui/core";
11
11
  import { marksByDisplay, type Mark } from "./view-plan";
12
- import { dimmedTheme } from "./theme";
13
12
  import {
14
13
  DEFAULT_KEYS,
15
14
  DEFAULT_QUICK_ACTIONS,
@@ -44,11 +43,12 @@ import { groupInbox, projectName, threadTitle } from "./components/session-tree"
44
43
  import { ThreadTree } from "./components/ThreadTree";
45
44
  import { ChangesFileTree } from "./components/ChangesColumn";
46
45
  import { ProjectTreeView } from "./components/ProjectTreeView";
47
- import { AnnotatableFileView } from "./components/AnnotatableFileView";
48
46
  import { AppShell, type ProjectPanelMode } from "./components/AppShell";
49
47
  import { EditorGrid } from "./components/EditorGrid";
50
- import type { EditorTab } from "./components/editor-grid";
48
+ import { GridTabContent } from "./components/GridTabContent";
51
49
  import { useChangesWorkbench } from "./use-changes-workbench";
50
+ import { useRememberLayout } from "./use-remember-layout";
51
+ import type { LaunchLayout } from "./launch-layout";
52
52
  import { ThreadFooter } from "./components/ThreadFooter";
53
53
  import { ConfirmCard } from "./components/ConfirmCard";
54
54
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
@@ -59,14 +59,10 @@ import {
59
59
  THREAD_CHORD_ENTRIES,
60
60
  TREE_CHORD_ENTRIES,
61
61
  } from "./thread-chords";
62
- import {
63
- DiffContentView,
64
- type DiffFoldControls,
65
- type DiffContentViewProps,
66
- } from "./components/DiffContentView";
67
- import { commentCountsByFile, fileTargetMarks, marksByRows, type DiffRow } from "./view-diff";
68
- import { annotationTarget, type Anchor } from "@cueloop/schema";
69
- import type { DiffFileContents, Thread } from "@cueloop/schema";
62
+ import { type DiffFoldControls } from "./components/DiffContentView";
63
+ import { commentCountsByFile } from "./view-diff";
64
+ import { annotationTarget } from "@cueloop/schema";
65
+ import type { Artifact, DiffFileContents, Thread } from "@cueloop/schema";
70
66
  import { PrototypePixels } from "./prototype-pixels";
71
67
  import type { PrototypeElement } from "./prototype-browser";
72
68
  import {
@@ -127,6 +123,10 @@ export interface AppProps {
127
123
  * light-on-light. Defaults to dark - the historical assumption.
128
124
  */
129
125
  appearance?: Appearance;
126
+ /** The pane composition to open in; a create-command sets it, a bare launch restores the remembered one. */
127
+ layout?: LaunchLayout;
128
+ /** Serve mode: the frozen working-tree diff an observer reads for the served workbench thread. */
129
+ servedArtifact?: Artifact;
130
130
  }
131
131
 
132
132
  /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
@@ -144,163 +144,6 @@ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean)
144
144
  return isOwner && !resolved && !observer;
145
145
  }
146
146
 
147
- /** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
148
- type DiffSurfaceProps = Pick<
149
- DiffContentViewProps,
150
- | "session"
151
- | "quickActions"
152
- | "observer"
153
- | "commentsEnabled"
154
- | "resolved"
155
- | "suspended"
156
- | "onComposingChange"
157
- | "onObserverBlocked"
158
- | "onCursorChange"
159
- | "focusedAnnotationId"
160
- | "onFocusAnnotation"
161
- | "onAnnotate"
162
- | "onReply"
163
- | "onUpdateAnnotation"
164
- | "onExit"
165
- >;
166
-
167
- /** The Changes tab body: the whole diff in one scroll container, or a bare hint when nothing changed. */
168
- function ChangesTabBody(props: {
169
- rows: DiffRow[];
170
- surface: DiffSurfaceProps;
171
- rejectedRows: Set<number>;
172
- fold?: DiffFoldControls;
173
- fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
174
- split?: boolean;
175
- dimmed: boolean;
176
- theme: Theme;
177
- }): React.ReactNode {
178
- const annotations = props.surface.session.annotations;
179
- // a diff thread's Changes view is the artifact itself; any other thread's is the working-tree diff,
180
- // whose notes carry a file target and resolve per file - each surface paints only its own notes
181
- const forArtifact = props.surface.session.artifact.type === "diff";
182
- const marks = useMemo(
183
- () =>
184
- forArtifact
185
- ? marksByRows(
186
- annotations.filter((annotation) => annotationTarget(annotation).kind === "artifact"),
187
- props.rows,
188
- props.surface.focusedAnnotationId,
189
- )
190
- : fileTargetMarks(annotations, props.rows, props.surface.focusedAnnotationId),
191
- [annotations, forArtifact, props.rows, props.surface.focusedAnnotationId],
192
- );
193
-
194
- if (props.rows.length === 0) {
195
- return (
196
- <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
197
- <text fg={props.theme.textDim}>No changes</text>
198
- </box>
199
- );
200
- }
201
-
202
- return (
203
- <DiffContentView
204
- rows={props.rows}
205
- marks={marks}
206
- {...props.surface}
207
- rejectedRows={props.rejectedRows}
208
- fold={props.fold}
209
- fileStats={props.fileStats}
210
- split={props.split}
211
- theme={props.dimmed ? dimmedTheme(props.theme) : undefined}
212
- />
213
- );
214
- }
215
-
216
- /** An editor tab's body: the whole diff for the Changes tab, a file's diff or contents for a file tab. */
217
- function GridTabContent(props: {
218
- tab: EditorTab;
219
- rows: DiffRow[];
220
- surface: DiffSurfaceProps;
221
- rejectedRows: Set<number>;
222
- fold?: DiffFoldControls;
223
- fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
224
- split?: boolean;
225
- dimmed: boolean;
226
- readFile: (path: string) => Promise<string | null>;
227
- /** Persist a comment on a project file; the file view built the anchor against its own lines. */
228
- onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
229
- theme: Theme;
230
- }): React.ReactNode {
231
- const { tab } = props;
232
- if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
233
- const filePath = tab.path;
234
-
235
- return (
236
- <AnnotatableFileView
237
- path={filePath}
238
- loadContents={props.readFile}
239
- session={props.surface.session}
240
- quickActions={props.surface.quickActions}
241
- observer={props.surface.observer}
242
- resolved={props.surface.resolved}
243
- suspended={props.surface.suspended}
244
- focusedAnnotationId={props.surface.focusedAnnotationId}
245
- onFocusAnnotation={props.surface.onFocusAnnotation}
246
- onComposingChange={props.surface.onComposingChange}
247
- onCursorChange={props.surface.onCursorChange}
248
- onObserverBlocked={props.surface.onObserverBlocked}
249
- onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
250
- onReply={props.surface.onReply}
251
- onUpdateAnnotation={props.surface.onUpdateAnnotation}
252
- onExit={props.surface.onExit}
253
- theme={props.theme}
254
- />
255
- );
256
- }
257
- if (tab.kind !== "file") {
258
- return (
259
- <ChangesTabBody
260
- rows={props.rows}
261
- surface={props.surface}
262
- rejectedRows={props.rejectedRows}
263
- fold={props.fold}
264
- fileStats={props.fileStats}
265
- split={props.split}
266
- dimmed={props.dimmed}
267
- theme={props.theme}
268
- />
269
- );
270
- }
271
- // a single-file tab shows that file's rows alone, so its row indices are its own: comments and
272
- // the caret report back in whole-diff indices, and the fold controls (a band to fold) do not apply
273
- const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
274
- const rows = fileRowIndices.map((index) => props.rows[index]!);
275
- const wholeIndex = (rowIndex: number): number => fileRowIndices[rowIndex] ?? rowIndex;
276
- const rejectedRows = new Set(
277
- fileRowIndices.flatMap((index, rowIndex) => (props.rejectedRows.has(index) ? [rowIndex] : [])),
278
- );
279
-
280
- return (
281
- <ChangesTabBody
282
- rows={rows}
283
- surface={{
284
- ...props.surface,
285
- onCursorChange: (rowIndex) => props.surface.onCursorChange?.(wholeIndex(rowIndex)),
286
- onAnnotate: (span, body) =>
287
- props.surface.onAnnotate(
288
- {
289
- start: { blockIndex: wholeIndex(span.start.blockIndex), char: span.start.char },
290
- end: { blockIndex: wholeIndex(span.end.blockIndex), char: span.end.char },
291
- },
292
- body,
293
- ),
294
- }}
295
- rejectedRows={rejectedRows}
296
- fileStats={props.fileStats}
297
- split={props.split}
298
- dimmed={props.dimmed}
299
- theme={props.theme}
300
- />
301
- );
302
- }
303
-
304
147
  /** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
305
148
  function ProjectPanelBody(props: {
306
149
  mode: ProjectPanelMode;
@@ -381,6 +224,14 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
381
224
  ];
382
225
  }
383
226
 
227
+ /** The Threads sidebar opens from the launch layout; a bare launch opens it only on the inbox. */
228
+ function initialThreadsOpen(
229
+ layout: LaunchLayout | undefined,
230
+ sessionId: string | undefined,
231
+ ): boolean {
232
+ return layout ? layout.threads : sessionId === undefined;
233
+ }
234
+
384
235
  export function App({
385
236
  home,
386
237
  sessionId,
@@ -394,6 +245,8 @@ export function App({
394
245
  role = "owner",
395
246
  selfAuthor,
396
247
  appearance = "dark",
248
+ layout,
249
+ servedArtifact,
397
250
  }: AppProps): React.ReactNode {
398
251
  const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
399
252
  const controller = useMemo(
@@ -407,6 +260,7 @@ export function App({
407
260
  clock,
408
261
  openClient,
409
262
  shareTransport,
263
+ servedArtifact,
410
264
  }),
411
265
  // eslint-disable-next-line react-hooks/exhaustive-deps
412
266
  [home, sessionId],
@@ -461,9 +315,19 @@ export function App({
461
315
  // picking a thread keeps the sidebar as it was. It opens when the app lands with
462
316
  // nothing selected (pick a thread) and stays collapsed on a direct thread open,
463
317
  // where the thread owns the width.
464
- const [sidebarOpen, setSidebarOpen] = useState(sessionId === undefined);
318
+ const [sidebarOpen, setSidebarOpen] = useState(() => initialThreadsOpen(layout, sessionId));
465
319
  // the Changes + Project right region and its editor grid (tabs, splits, zoom)
466
- const workbench = useChangesWorkbench();
320
+ const workbench = useChangesWorkbench({ layout });
321
+ // only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
322
+ // workbench stays inactive with no session and never clobbers what the shell saved
323
+ useRememberLayout(
324
+ layout,
325
+ session !== null,
326
+ sidebarOpen,
327
+ workbench.changesOpen,
328
+ workbench.projectOpen,
329
+ workbench.zoomed,
330
+ );
467
331
  const [mode, setMode] = useState<Mode>({ type: "normal" });
468
332
  // the top-left settings gear drop-down and the centered dialog it opens
469
333
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
@@ -813,6 +677,7 @@ export function App({
813
677
  onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
814
678
  quickActions={quickActions}
815
679
  onWelcomeComposingChange={setWelcomeComposing}
680
+ layout={layout}
816
681
  />
817
682
  </PaletteNamesContext.Provider>
818
683
  </SlashSkillsContext.Provider>
@@ -15,6 +15,8 @@ import { testRender } from "@opentui/react/test-utils";
15
15
  import { DaemonServer } from "@cueloop/daemon";
16
16
  import type { Thread } from "@cueloop/schema";
17
17
  import { App } from "./App";
18
+ import { loadConfig } from "./config";
19
+ import { planLayout, reviewLayout } from "./launch-layout";
18
20
  import {
19
21
  dragText,
20
22
  isolateUserConfig,
@@ -247,6 +249,29 @@ describe("the four-pane workbench", () => {
247
249
  expect(diffToggleColumn(setup)).toBeGreaterThan(0);
248
250
  });
249
251
 
252
+ test("a create-command session remembers its zoom toggle for the next bare launch", async () => {
253
+ // a create-command opens the review zoomed; toggling zoom off must persist to the remembered layout
254
+ const setup = await renderReadyApp(
255
+ <App home={home} sessionId={session.id} layout={reviewLayout()} />,
256
+ {
257
+ width: 160,
258
+ height: 20,
259
+ },
260
+ );
261
+ await waitForText(setup, "store.ts");
262
+
263
+ const zoom = locateText(setup, NERD.zoom);
264
+ await setup.mockMouse.click(zoom.column, zoom.row);
265
+ await waitForState(
266
+ setup,
267
+ () =>
268
+ loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.zoomChanges === false,
269
+ );
270
+ expect(loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.zoomChanges).toBe(
271
+ false,
272
+ );
273
+ });
274
+
250
275
  test("selecting a non-diff thread while zoomed exits zoom and restores the Thread pane", async () => {
251
276
  // a second, non-diff thread to navigate to; its sync has no Changes editor to fill a zoom
252
277
  server.core.sessionCreate({
@@ -303,11 +328,11 @@ describe("the four-pane workbench", () => {
303
328
  const setup = await renderApp();
304
329
 
305
330
  // the tip is not painted until the pointer is over the control
306
- expect(setup.captureCharFrame()).not.toContain("Toggle Right Sidebar");
331
+ expect(setup.captureCharFrame()).not.toContain("Toggle Sidebar");
307
332
 
308
333
  await setup.mockMouse.moveTo(rightToggleColumn(setup), HEADER_ROW);
309
334
  // the label surfaces at the root, escaping the header cell that would otherwise clip it
310
- await waitForText(setup, "Toggle Right Sidebar");
335
+ await waitForText(setup, "Toggle Sidebar");
311
336
  });
312
337
  });
313
338
 
@@ -396,6 +421,82 @@ describe("the bare-launch welcome shell", () => {
396
421
  expect(setup.captureCharFrame()).toContain("README.md");
397
422
  });
398
423
 
424
+ test("a review layout opens the Changes panel, listing the working-tree changes", async () => {
425
+ const setup = await testRender(
426
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={reviewLayout()} />,
427
+ { width: 180, height: 14 },
428
+ );
429
+ await waitForText(setup, "README.md");
430
+ expect(setup.captureCharFrame()).toContain("README.md");
431
+ });
432
+
433
+ test("a plan layout opens with the right region closed, so no Changes panel shows", async () => {
434
+ const setup = await testRender(
435
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={planLayout()} />,
436
+ { width: 180, height: 14 },
437
+ );
438
+ await waitForText(setup, "cueloop");
439
+ // rightSidebar "off": neither the Changes list nor the launch repo's files paint
440
+ expect(setup.captureCharFrame()).not.toContain("README.md");
441
+ });
442
+
443
+ test("a hand-edited off+zoom layout drops the zoom so the center is not stranded blank", async () => {
444
+ // zoom fills the middle with the Changes editor; with the region off there is none, so honoring zoom
445
+ // would hide the Thread pane over a blank center. The Thread placeholder must still render.
446
+ const setup = await testRender(
447
+ <App
448
+ home={welcomeHome}
449
+ sessionId={undefined}
450
+ cwd={welcomeRepo}
451
+ layout={{ threads: true, rightSidebar: "off", zoomChanges: true }}
452
+ />,
453
+ { width: 180, height: 14 },
454
+ );
455
+ await waitForText(setup, "no threads");
456
+ expect(setup.captureCharFrame()).toContain("no threads");
457
+ });
458
+
459
+ test("closing the right region on the vanilla shell remembers it for the next launch", async () => {
460
+ const setup = await testRender(
461
+ <App
462
+ home={welcomeHome}
463
+ sessionId={undefined}
464
+ cwd={welcomeRepo}
465
+ layout={{ threads: true, rightSidebar: "changes", zoomChanges: false }}
466
+ />,
467
+ { width: 180, height: 14 },
468
+ );
469
+ await waitForText(setup, "README.md");
470
+
471
+ // close the Changes/Project region; the remembered layout follows it to "off"
472
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
473
+ await waitForState(
474
+ setup,
475
+ () =>
476
+ loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.rightSidebar ===
477
+ "off",
478
+ );
479
+ expect(loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout).toEqual({
480
+ threads: true,
481
+ rightSidebar: "off",
482
+ zoomChanges: false,
483
+ });
484
+ });
485
+
486
+ test("clicking a changed file in the bare shell opens its diff, not its contents", async () => {
487
+ const setup = await renderWelcome();
488
+
489
+ await waitForText(setup, "README.md");
490
+ const readme = locateText(setup, "README.md");
491
+ await setup.mockMouse.click(readme.column, readme.row);
492
+
493
+ // a diff shows both the removed old line and the added new line; contents would show only the new
494
+ await waitForText(setup, "A tiny tracked repo.");
495
+ const frame = setup.captureCharFrame();
496
+ expect(frame).toContain("A tiny tracked repo.");
497
+ expect(frame).toContain("edited in the working tree");
498
+ });
499
+
399
500
  test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
400
501
  const setup = await renderWelcome();
401
502
 
@@ -1,9 +1,11 @@
1
1
  import { ScrollArea } from "./components/ScrollArea";
2
- import React, { useEffect, useState, type Dispatch, type SetStateAction } from "react";
2
+ import React, { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
3
3
  import type { DiffFileContents, Thread, VerdictKind } from "@cueloop/schema";
4
4
  import { returnPaneFor } from "@cueloop/schema";
5
5
  import type { Theme } from "./theme";
6
6
  import type { QuickAction } from "./config";
7
+ import type { LaunchLayout } from "./launch-layout";
8
+ import { useRememberLayout } from "./use-remember-layout";
7
9
  import type { Mode, TreeAsk } from "./intent-dispatch";
8
10
  import type { Intent } from "./keymap";
9
11
  import type { ReviewController, ToastState } from "./thread-controller";
@@ -23,7 +25,8 @@ import { AppShell, type ProjectPanelMode } from "./components/AppShell";
23
25
  import { EditorGrid } from "./components/EditorGrid";
24
26
  import { ProjectTreeView } from "./components/ProjectTreeView";
25
27
  import { ChangesFileTree } from "./components/ChangesColumn";
26
- import { BareWorkbenchFileView } from "./components/BareWorkbenchFileView";
28
+ import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
29
+ import { GridTabContent, type DiffSurfaceProps } from "./components/GridTabContent";
27
30
  import { useChangesWorkbench } from "./use-changes-workbench";
28
31
  import { ConfirmDialog } from "./components/ConfirmDialog";
29
32
  import { PromptDialog } from "./components/PromptDialog";
@@ -88,15 +91,22 @@ export function MenuChrome(props: {
88
91
  }
89
92
 
90
93
  /** The welcome shell's Project pane: the launch repo's changed files in changes mode, its full tree otherwise. */
94
+ /** A shared empty rejected-rows set: the bare-launch diff rejects nothing, and this keeps a stable ref. */
95
+ const EMPTY_ROWS: Set<number> = new Set();
96
+
91
97
  function WelcomeProjectPanel({
92
98
  mode,
93
99
  controller,
94
- onOpenFile,
100
+ onOpenChangedFile,
101
+ onOpenProjectFile,
95
102
  theme,
96
103
  }: {
97
104
  mode: ProjectPanelMode;
98
105
  controller: ReviewController;
99
- onOpenFile: (path: string) => void;
106
+ /** Changes tree click -> open the file's working-tree diff. */
107
+ onOpenChangedFile: (path: string) => void;
108
+ /** Project tree click -> open the file's read-only contents. */
109
+ onOpenProjectFile: (path: string) => void;
100
110
  theme: Theme;
101
111
  }): React.ReactNode {
102
112
  const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
@@ -119,13 +129,13 @@ function WelcomeProjectPanel({
119
129
  }, [controller]);
120
130
 
121
131
  if (mode === "changes") {
122
- return <ChangesFileTree files={changes} onSelectFile={onOpenFile} theme={theme} />;
132
+ return <ChangesFileTree files={changes} onSelectFile={onOpenChangedFile} theme={theme} />;
123
133
  }
124
134
 
125
135
  return (
126
136
  <ProjectTreeView
127
137
  loadFiles={() => controller.repoFiles()}
128
- onSelectFile={onOpenFile}
138
+ onSelectFile={onOpenProjectFile}
129
139
  theme={theme}
130
140
  />
131
141
  );
@@ -156,6 +166,8 @@ export function NoThreadShell(props: {
156
166
  quickActions: QuickAction[];
157
167
  /** Reports the welcome composer's open state, so the shell suspends its inbox keys while typing. */
158
168
  onWelcomeComposingChange: (composing: boolean) => void;
169
+ /** The pane composition this vanilla launch restores; changes here are remembered for the next one. */
170
+ layout?: LaunchLayout;
159
171
  }): React.ReactNode {
160
172
  const {
161
173
  rows,
@@ -173,12 +185,56 @@ export function NoThreadShell(props: {
173
185
  onRename,
174
186
  quickActions,
175
187
  onWelcomeComposingChange,
188
+ layout,
176
189
  } = props;
177
190
  const confirming = mode.type === "confirmDelete" ? mode : null;
178
191
  // The bare-launch shell is the same four panes as a thread: the Thread pane waits in its empty state
179
192
  // and a disposable Welcome tab rides in the Changes editor until a thread or diff is opened.
180
- const workbench = useChangesWorkbench({ seed: "welcome" });
181
- const openRepoFile = (path: string): void => workbench.openFile(path, "contents");
193
+ const workbench = useChangesWorkbench({ seed: "welcome", layout });
194
+ // the vanilla shell remembers the composition the user leaves it in, for the next bare launch
195
+ useRememberLayout(
196
+ layout,
197
+ true,
198
+ sidebarOpen,
199
+ workbench.changesOpen,
200
+ workbench.projectOpen,
201
+ workbench.zoomed,
202
+ );
203
+ // the Changes tree opens a file's working-tree diff; the Project tree opens read-only contents
204
+ const openChangedFile = (path: string): void => {
205
+ // no thread means no live-diff refresh loop, so re-capture on open or a long-lived shell goes
206
+ // stale; the catch keeps a fire-and-forget refresh from throwing when the shell tears down
207
+ void controller.repoChanges().catch(() => undefined);
208
+ workbench.openFile(path, "diff");
209
+ };
210
+ const openProjectFile = (path: string): void => workbench.openFile(path, "contents");
211
+ // a bare launch has no thread yet, so the diff renders against a draft session; the first note
212
+ // promotes it to the per-repo workbench thread (commentOnWorkbenchDiff)
213
+ const draft = useMemo(() => draftThread(), []);
214
+ const bareSurface: DiffSurfaceProps = {
215
+ session: draft,
216
+ quickActions,
217
+ observer: false,
218
+ commentsEnabled: true,
219
+ resolved: false,
220
+ suspended: false,
221
+ onComposingChange: onWelcomeComposingChange,
222
+ onObserverBlocked: () => {},
223
+ onCursorChange: () => {},
224
+ focusedAnnotationId: undefined,
225
+ onFocusAnnotation: () => {},
226
+ onAnnotate: (span, body) =>
227
+ void controller.commentOnWorkbenchDiff(
228
+ span.start.blockIndex,
229
+ span.start.char,
230
+ span.end.char,
231
+ span.end.blockIndex,
232
+ body,
233
+ ),
234
+ onReply: () => undefined,
235
+ onUpdateAnnotation: () => {},
236
+ onExit: () => {},
237
+ };
182
238
 
183
239
  return (
184
240
  <ThemeProvider theme={theme}>
@@ -241,7 +297,7 @@ export function NoThreadShell(props: {
241
297
  onComposingChange={onWelcomeComposingChange}
242
298
  theme={theme}
243
299
  />
244
- ) : (
300
+ ) : tab.fileView === "contents" ? (
245
301
  <BareWorkbenchFileView
246
302
  path={tab.path ?? ""}
247
303
  controller={controller}
@@ -250,6 +306,23 @@ export function NoThreadShell(props: {
250
306
  onExit={() => {}}
251
307
  theme={theme}
252
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
+ />
253
326
  )
254
327
  }
255
328
  theme={theme}
@@ -259,7 +332,8 @@ export function NoThreadShell(props: {
259
332
  <WelcomeProjectPanel
260
333
  mode={workbench.projectMode}
261
334
  controller={controller}
262
- onOpenFile={openRepoFile}
335
+ onOpenChangedFile={openChangedFile}
336
+ onOpenProjectFile={openProjectFile}
263
337
  theme={theme}
264
338
  />
265
339
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The collapsed-sidebar brand row keeps the gear, the Threads toggle, and the
3
+ * "cueloop" mark on one line. A long thread title used to shrink the brand box
4
+ * until "cueloop" and the toggle wrapped onto the header's underline row; the
5
+ * title truncates instead, so the brand never shrinks.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { testRender } from "@opentui/react/test-utils";
10
+ import React from "react";
11
+ import { AppShell } from "./AppShell";
12
+ import { NERD } from "./primitives/icons";
13
+ import { settle } from "../test-support";
14
+
15
+ const LONG_TITLE = "working tree @ GRO-1981-fe-billing-promo-cookie-migration";
16
+
17
+ async function brandFrame(): Promise<string[]> {
18
+ const setup = await testRender(
19
+ <AppShell
20
+ sidebarOpen={false}
21
+ onToggleSidebar={() => {}}
22
+ onOpenMenu={() => {}}
23
+ threadsPanel={<text>threads</text>}
24
+ threadTitle={LONG_TITLE}
25
+ threadPanel={<text>review the changes on the right</text>}
26
+ changesOpen
27
+ projectOpen
28
+ onToggleChanges={() => {}}
29
+ onToggleProject={() => {}}
30
+ onToggleRight={() => {}}
31
+ projectMode="changes"
32
+ projectPanel={<text>project</text>}
33
+ changesPanel={<text>diff</text>}
34
+ />,
35
+ { width: 160, height: 12 },
36
+ );
37
+
38
+ await settle(setup);
39
+ await settle(setup);
40
+ const lines = setup.captureCharFrame().split("\n");
41
+
42
+ setup.renderer.destroy();
43
+
44
+ return lines;
45
+ }
46
+
47
+ describe("collapsed-sidebar brand row", () => {
48
+ test("the Threads toggle stays on the cueloop row and the title tails off in an ellipsis", async () => {
49
+ const lines = await brandFrame();
50
+ const brandRow = lines.findIndex((line) => line.includes("cueloop"));
51
+ const toggleRow = lines.findIndex((line) => line.includes(NERD.sidebarLeftOff));
52
+
53
+ expect(brandRow).toBeGreaterThanOrEqual(0);
54
+ expect(toggleRow).toBe(brandRow);
55
+ expect(lines[brandRow]).toContain("…");
56
+ expect(lines[brandRow]).not.toContain(LONG_TITLE);
57
+ });
58
+ });