@cueloop/client 0.1.0-alpha.67 → 0.1.0-alpha.69

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 (85) hide show
  1. package/package.json +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/ghostty-terminal.test.ts +2 -1
  56. package/src/ghostty-terminal.ts +27 -4
  57. package/src/intent-dispatch.test.ts +2 -0
  58. package/src/intent-dispatch.ts +1 -1
  59. package/src/key-bindings.test.ts +1 -1
  60. package/src/key-bindings.ts +5 -1
  61. package/src/keymap.ts +1 -1
  62. package/src/prototype-browser.ts +8 -0
  63. package/src/prototype-pixels.tsx +35 -0
  64. package/src/ready-signal.test.ts +85 -0
  65. package/src/ready-signal.ts +52 -0
  66. package/src/session-controller.live-diff.test.ts +198 -0
  67. package/src/session-controller.ts +141 -26
  68. package/src/slash-palette.test.ts +54 -0
  69. package/src/slash-palette.ts +32 -18
  70. package/src/test-support.test.ts +12 -0
  71. package/src/test-support.ts +69 -10
  72. package/src/theme-presets.ts +20 -0
  73. package/src/theme.ts +18 -0
  74. package/src/thread-chords.ts +1 -1
  75. package/src/use-annotation-surface.tsx +12 -2
  76. package/src/use-settings-dialog.tsx +4 -4
  77. package/src/view-diff.test.ts +44 -1
  78. package/src/view-diff.ts +81 -0
  79. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  80. package/src/components/AnnotationCard.stories.tsx +0 -94
  81. package/src/components/AnnotationCard.tsx +0 -250
  82. package/src/components/MarkerPopover.stories.tsx +0 -58
  83. package/src/components/MarkerPopover.tsx +0 -109
  84. package/src/components/WelcomeSurface.stories.tsx +0 -12
  85. package/src/components/WelcomeSurface.tsx +0 -52
package/src/App.tsx CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * The cueloop TUI: state wiring, keymap dispatch, and layout composition -
3
- * nothing else. Rendering lives in components/, daemon IO and the mutation
4
- * primitives in session-controller.ts, the keyboard grammar in keymap.ts with
5
- * binding resolution and status hints from key-bindings.ts, and theming in
6
- * the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
7
- * span on one native renderer selection); annotation text lives in the rail
8
- * while the document keeps only the highlight, and one selected id drives
9
- * both sides.
10
- */
11
-
12
1
  import React, {
13
2
  useCallback,
14
3
  useEffect,
@@ -45,14 +34,15 @@ import type { SessionClient } from "@cueloop/daemon/client";
45
34
  import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
46
35
  import { reduceKey, type KeyState } from "./keymap";
47
36
  import { KeyBindings, type CheatsheetSection } from "./key-bindings";
37
+ import { useReadySignal } from "./ready-signal";
48
38
  import { ThemeProvider } from "./components/theme-context";
49
39
  import { Button } from "./components/primitives/Button";
50
40
  import { Toolbar } from "./components/primitives/Toolbar";
51
41
  import { groupInbox, projectName, threadTitle } from "./components/session-tree";
52
- import { InboxList } from "./components/InboxList";
42
+ import { ThreadTree } from "./components/ThreadTree";
53
43
  import { ChangesFileTree } from "./components/ChangesColumn";
54
44
  import { ProjectTreeView } from "./components/ProjectTreeView";
55
- import { FileContentsView } from "./components/FileContentsView";
45
+ import { AnnotatableFileView } from "./components/AnnotatableFileView";
56
46
  import { AppShell, type ProjectPanelMode } from "./components/AppShell";
57
47
  import { EditorGrid } from "./components/EditorGrid";
58
48
  import type { EditorTab } from "./components/editor-grid";
@@ -67,16 +57,22 @@ import {
67
57
  THREAD_CHORD_ENTRIES,
68
58
  TREE_CHORD_ENTRIES,
69
59
  } from "./thread-chords";
70
- import { DiffSheet, type DiffFoldControls, type DiffSheetProps } from "./components/DiffSheet";
71
- import { commentCountsByFile, marksByRows, type DiffRow } from "./view-diff";
72
- import type { DiffFileContents } from "@cueloop/schema";
73
- import { PrototypeSheet } from "./components/PrototypeSheet";
60
+ import {
61
+ DiffContentView,
62
+ type DiffFoldControls,
63
+ type DiffContentViewProps,
64
+ } from "./components/DiffContentView";
65
+ import { commentCountsByFile, fileTargetMarks, marksByRows, type DiffRow } from "./view-diff";
66
+ import { annotationTarget, type Anchor } from "@cueloop/schema";
67
+ import type { DiffFileContents, ReviewSession } from "@cueloop/schema";
68
+ import { PrototypePixels } from "./prototype-pixels";
74
69
  import type { PrototypeElement } from "./prototype-browser";
75
70
  import {
76
71
  buildRenderFlags,
77
72
  buildSubmitConfirmState,
78
73
  computeRoleCapabilities,
79
74
  deriveReviewFlags,
75
+ isPixelPrototypeMode,
80
76
  isCompletionOverlayPhase,
81
77
  isWalking,
82
78
  resolveOverlay,
@@ -105,6 +101,8 @@ export interface AppProps {
105
101
  */
106
102
  readOnly?: boolean;
107
103
  onExit?: (code: number) => void;
104
+ /** Fired once, after the first frame that paints a usable screen with its keyboard handlers live (ready-signal.ts). */
105
+ onReady?: () => void;
108
106
  /** Timer source for the auto-close countdown; tests inject a ManualClock. */
109
107
  clock?: Clock;
110
108
  /** Session source; the sharing gateway injects a blob-backed client. */
@@ -146,10 +144,11 @@ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean)
146
144
 
147
145
  /** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
148
146
  type DiffSurfaceProps = Pick<
149
- DiffSheetProps,
147
+ DiffContentViewProps,
150
148
  | "session"
151
149
  | "quickActions"
152
150
  | "observer"
151
+ | "commentsEnabled"
153
152
  | "resolved"
154
153
  | "suspended"
155
154
  | "onComposingChange"
@@ -174,10 +173,20 @@ function ChangesTabBody(props: {
174
173
  dimmed: boolean;
175
174
  theme: Theme;
176
175
  }): React.ReactNode {
176
+ const annotations = props.surface.session.annotations;
177
+ // a diff thread's Changes view is the artifact itself; any other thread's is the working-tree diff,
178
+ // whose notes carry a file target and resolve per file - each surface paints only its own notes
179
+ const forArtifact = props.surface.session.artifact.type === "diff";
177
180
  const marks = useMemo(
178
181
  () =>
179
- marksByRows(props.surface.session.annotations, props.rows, props.surface.focusedAnnotationId),
180
- [props.surface.session.annotations, props.rows, props.surface.focusedAnnotationId],
182
+ forArtifact
183
+ ? marksByRows(
184
+ annotations.filter((annotation) => annotationTarget(annotation).kind === "artifact"),
185
+ props.rows,
186
+ props.surface.focusedAnnotationId,
187
+ )
188
+ : fileTargetMarks(annotations, props.rows, props.surface.focusedAnnotationId),
189
+ [annotations, forArtifact, props.rows, props.surface.focusedAnnotationId],
181
190
  );
182
191
 
183
192
  if (props.rows.length === 0) {
@@ -189,7 +198,7 @@ function ChangesTabBody(props: {
189
198
  }
190
199
 
191
200
  return (
192
- <DiffSheet
201
+ <DiffContentView
193
202
  rows={props.rows}
194
203
  marks={marks}
195
204
  {...props.surface}
@@ -213,11 +222,35 @@ function GridTabContent(props: {
213
222
  split?: boolean;
214
223
  dimmed: boolean;
215
224
  readFile: (path: string) => Promise<string | null>;
225
+ /** Persist a comment on a project file; the file view built the anchor against its own lines. */
226
+ onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
216
227
  theme: Theme;
217
228
  }): React.ReactNode {
218
229
  const { tab } = props;
219
230
  if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
220
- return <FileContentsView path={tab.path} loadContents={props.readFile} theme={props.theme} />;
231
+ const filePath = tab.path;
232
+
233
+ return (
234
+ <AnnotatableFileView
235
+ path={filePath}
236
+ loadContents={props.readFile}
237
+ session={props.surface.session}
238
+ quickActions={props.surface.quickActions}
239
+ observer={props.surface.observer}
240
+ resolved={props.surface.resolved}
241
+ suspended={props.surface.suspended}
242
+ focusedAnnotationId={props.surface.focusedAnnotationId}
243
+ onFocusAnnotation={props.surface.onFocusAnnotation}
244
+ onComposingChange={props.surface.onComposingChange}
245
+ onCursorChange={props.surface.onCursorChange}
246
+ onObserverBlocked={props.surface.onObserverBlocked}
247
+ onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
248
+ onReply={props.surface.onReply}
249
+ onUpdateAnnotation={props.surface.onUpdateAnnotation}
250
+ onExit={props.surface.onExit}
251
+ theme={props.theme}
252
+ />
253
+ );
221
254
  }
222
255
  if (tab.kind !== "file") {
223
256
  return (
@@ -319,6 +352,15 @@ function ProjectPanelBody(props: {
319
352
  );
320
353
  }
321
354
 
355
+ /** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
356
+ function usableScreenReached(
357
+ error: string | null,
358
+ session: ReviewSession | null,
359
+ inbox: ReviewSession[] | null,
360
+ ): boolean {
361
+ return Boolean(error) || session !== null || inbox !== null;
362
+ }
363
+
322
364
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
323
365
  function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
324
366
  const base = keyBindings.cheatsheet();
@@ -343,6 +385,7 @@ export function App({
343
385
  cwd,
344
386
  readOnly = false,
345
387
  onExit,
388
+ onReady,
346
389
  clock,
347
390
  openClient,
348
391
  shareTransport,
@@ -372,8 +415,8 @@ export function App({
372
415
 
373
416
  return () => controller.close();
374
417
  }, [controller]);
375
- // stable across renders so the memoized PrototypeSheet is not re-rendered by
376
- // unrelated App state (status ticks, a rail-width drag)
418
+ // stable across renders so the memoized PrototypeContentView is not
419
+ // re-rendered by unrelated App state (status ticks, a rail-width drag)
377
420
  const onCommentPrototype = useCallback(
378
421
  (element: PrototypeElement, body: string) =>
379
422
  controller.annotatePrototype(element.selector, element.quote, body),
@@ -424,7 +467,7 @@ export function App({
424
467
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
425
468
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
426
469
  // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
427
- const [diffView, setDiffView] = useState<DiffViewMode>("unified");
470
+ const [diffView, setDiffView] = useState<DiffViewMode>("split");
428
471
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
429
472
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
430
473
  const [railTab, setRailTab] = useState<RailTab>("review");
@@ -444,10 +487,13 @@ export function App({
444
487
  const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
445
488
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
446
489
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
490
+ // opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
491
+ const [prototypePixels, setPrototypePixels] = useState(false);
447
492
 
448
493
  useEffect(() => {
449
494
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
450
495
 
496
+ setPrototypePixels(config.experimental.prototypePixels);
451
497
  keysRef.current = config.keys;
452
498
  keyBindings.setKeys(config.keys);
453
499
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
@@ -517,7 +563,14 @@ export function App({
517
563
  const marks = useMemo(
518
564
  () =>
519
565
  session
520
- ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined)
566
+ ? marksByDisplay(
567
+ // the plan thread shows only artifact notes; Changes-diff notes render on that surface
568
+ session.annotations.filter(
569
+ (annotation) => annotationTarget(annotation).kind === "artifact",
570
+ ),
571
+ display,
572
+ pulsedAnnotationId ?? undefined,
573
+ )
521
574
  : new Map<number, Mark[]>(),
522
575
  [session, display, pulsedAnnotationId],
523
576
  );
@@ -532,6 +585,13 @@ export function App({
532
585
  return ids;
533
586
  }, [marks]);
534
587
  const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
588
+ // a prototype is a markdown design doc by default; only the opt-in experimental
589
+ // pixel mode (flag on + an HTML entry) renders it as a kitty mockup instead
590
+ const isPixelPrototype = isPixelPrototypeMode(
591
+ isPrototype,
592
+ prototypePixels,
593
+ session?.artifact.meta.prototypePath,
594
+ );
535
595
  // comments per changed-file path, for the changed-files tree and tab badges
536
596
  const diffCommentCounts = useMemo(
537
597
  () => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
@@ -539,11 +599,12 @@ export function App({
539
599
  );
540
600
  // entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
541
601
  workbench.syncSession(session?.id, isDiff);
542
- // plans and replies open in the thread view, diffs in the diff sheet: both drive the shared
543
- // annotation surface and own the document grammar; only the prototype keeps the keymap
544
- const threadViewActive = session !== null && !isPrototype;
602
+ // plans, replies, and the default (markdown) prototype open in the thread view, diffs in the
603
+ // diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
604
+ const threadViewActive = session !== null && !isPixelPrototype;
545
605
  const [threadComposing, setThreadComposing] = useState(false);
546
606
  const [prototypeComposing, setPrototypeComposing] = useState(false);
607
+ const [welcomeComposing, setWelcomeComposing] = useState(false);
547
608
  // sort position per annotation so the rail interleaves annotation and removal
548
609
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
549
610
  // annotation resolves to the display index it marked
@@ -624,12 +685,14 @@ export function App({
624
685
  runEditorHandOff,
625
686
  openCardEdit,
626
687
  toggleDiffView: () => {
627
- const next: DiffViewMode = diffView === "unified" ? "split" : "unified";
688
+ const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
628
689
 
629
690
  setDiffView(next);
630
691
  persistDiffView(next);
631
- if (next === "split" && !workbench.zoomed)
632
- controller.setStatus("split diff shows when zoomed");
692
+ // every toggle names the new mode; picking split on a narrow pane also says it needs the wide layout
693
+ if (next === "stacked") controller.setStatus("stacked diff");
694
+ else if (workbench.zoomed) controller.setStatus("split diff");
695
+ else controller.setStatus("split diff shows when zoomed");
633
696
  },
634
697
  });
635
698
 
@@ -658,9 +721,9 @@ export function App({
658
721
 
659
722
  return;
660
723
  }
661
- // The prototype compose textarea owns the keyboard while open: let it receive
662
- // the typed note instead of the global keymap acting on each letter.
663
- if (prototypeComposing) return;
724
+ // A compose textarea owns the keyboard while open: let it receive the typed note instead of the
725
+ // global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
726
+ if (prototypeComposing || welcomeComposing) return;
664
727
  if (menuDialog === "settings") return void handleSettingsKey(key.name);
665
728
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
666
729
  // the toast is non-modal: escape only dismisses it when nothing else owns
@@ -702,6 +765,9 @@ export function App({
702
765
  dispatch(intent);
703
766
  });
704
767
 
768
+ // after every keyboard hook above, so their subscriptions precede the signal
769
+ useReadySignal(usableScreenReached(error, session, inbox), onReady);
770
+
705
771
  // ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
706
772
  // reused by the inbox and by plan/diff review so the two never drift ──
707
773
  const menuChrome = (
@@ -735,6 +801,8 @@ export function App({
735
801
  pinnedIds={pinnedIds}
736
802
  onPin={togglePin}
737
803
  onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
804
+ quickActions={quickActions}
805
+ onWelcomeComposingChange={setWelcomeComposing}
738
806
  />
739
807
  ) : (
740
808
  <ConnectingScreen theme={theme} />
@@ -776,7 +844,7 @@ export function App({
776
844
  session: activeSession,
777
845
  isOwner,
778
846
  isDiff,
779
- isPrototype,
847
+ isPixelPrototype,
780
848
  resolved,
781
849
  menuDialog,
782
850
  resolvedIds,
@@ -812,7 +880,7 @@ export function App({
812
880
  onOpenMenu={() => setMenuDialog("settings")}
813
881
  threadsPanel={
814
882
  <scrollbox style={{ flexGrow: 1 }} focused={false}>
815
- <InboxList
883
+ <ThreadTree
816
884
  rows={grouped.rows}
817
885
  cursor={inboxCursor}
818
886
  activeId={activeSession.id}
@@ -843,10 +911,9 @@ export function App({
843
911
  threadPanel={
844
912
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
845
913
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
846
- {isPrototype ? (
847
- <PrototypeSheet
914
+ {isPixelPrototype ? (
915
+ <PrototypePixels
848
916
  prototypePath={prototypePath}
849
- quickActions={quickActions}
850
917
  canComment={prototypeCanComment}
851
918
  onCommentElement={onCommentPrototype}
852
919
  onComposingChange={setPrototypeComposing}
@@ -930,6 +997,7 @@ export function App({
930
997
  session: activeSession,
931
998
  quickActions,
932
999
  observer,
1000
+ commentsEnabled: true,
933
1001
  resolved,
934
1002
  suspended: threadViewSuspended || !groupFocused,
935
1003
  onComposingChange: setThreadComposing,
@@ -942,6 +1010,8 @@ export function App({
942
1010
  onCursorChange: setCursor,
943
1011
  focusedAnnotationId,
944
1012
  onFocusAnnotation: setFocusedAnnotationId,
1013
+ // a diff thread's Changes view is the artifact itself; any other thread's is the
1014
+ // live working-tree diff, so its notes carry a file target and anchor there
945
1015
  onAnnotate: (span, body) =>
946
1016
  void controller.annotate(
947
1017
  "comment",
@@ -950,6 +1020,7 @@ export function App({
950
1020
  span.end.char,
951
1021
  body,
952
1022
  span.end.blockIndex,
1023
+ isDiff ? { kind: "artifact" } : { kind: "file", path: "", rev: "worktree" },
953
1024
  ),
954
1025
  onReply: (rootAnnotationId, body) =>
955
1026
  void controller.reply(rootAnnotationId, body),
@@ -962,6 +1033,9 @@ export function App({
962
1033
  split={diffView === "split" && workbench.zoomed}
963
1034
  dimmed={walking}
964
1035
  readFile={(path) => controller.repoReadFile(path)}
1036
+ onAddFileComment={(path, anchor, body) =>
1037
+ void controller.addComment(anchor, { kind: "file", path, rev: "worktree" }, body)
1038
+ }
965
1039
  theme={theme}
966
1040
  />
967
1041
  )}
@@ -974,8 +1048,9 @@ export function App({
974
1048
  loadChanges={() => controller.repoChanges()}
975
1049
  loadProjectFiles={() => controller.repoFiles()}
976
1050
  reloadKey={activeSession.id}
977
- // a diff review opens a changed file as its captured diff; other threads show live contents
978
- onOpenChangedFile={(path) => workbench.openFile(path, isDiff ? "diff" : "contents")}
1051
+ // the Changes navigator always opens a changed file as a diff - a diff review shows its
1052
+ // captured snapshot, every other thread the live working-tree diff
1053
+ onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
979
1054
  onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
980
1055
  commentCounts={diffCommentCounts}
981
1056
  theme={theme}
@@ -5,7 +5,6 @@ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
9
  import type { ReviewSession } from "@cueloop/schema";
11
10
  import { App } from "./App";
@@ -14,6 +13,7 @@ import {
14
13
  isolateUserConfig,
15
14
  press,
16
15
  pressKey,
16
+ renderReadyApp,
17
17
  waitForState,
18
18
  waitForText,
19
19
  waitForTextGone,
@@ -66,7 +66,7 @@ afterEach(() => {
66
66
  });
67
67
 
68
68
  async function renderApp() {
69
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
69
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
70
70
  width: 120,
71
71
  height: 34,
72
72
  });
@@ -15,7 +15,16 @@ import { testRender } from "@opentui/react/test-utils";
15
15
  import { DaemonServer } from "@cueloop/daemon";
16
16
  import type { ReviewSession } from "@cueloop/schema";
17
17
  import { App } from "./App";
18
- import { isolateUserConfig, locateText, waitForState, waitForText } from "./test-support";
18
+ import {
19
+ dragText,
20
+ isolateUserConfig,
21
+ locateText,
22
+ pressKey,
23
+ typeText,
24
+ waitForState,
25
+ waitForText,
26
+ renderReadyApp,
27
+ } from "./test-support";
19
28
  import { NERD } from "./components/primitives/icons";
20
29
 
21
30
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
@@ -73,7 +82,7 @@ afterEach(() => {
73
82
  });
74
83
 
75
84
  async function renderApp() {
76
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
85
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
77
86
  width: 160,
78
87
  height: 20,
79
88
  });
@@ -183,17 +192,17 @@ describe("the four-pane workbench", () => {
183
192
  expect(diffToggleColumn(setup)).toBeGreaterThan(0);
184
193
  });
185
194
 
186
- test("the right-sidebar toggle collapses the region to a rail and reopens it", async () => {
195
+ test("the right-sidebar toggle closes the region and its header toggle reopens it", async () => {
187
196
  const setup = await renderApp();
188
197
 
189
198
  await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
190
199
  await waitForState(setup, () => !setup.captureCharFrame().includes("store.ts"));
191
200
 
192
- // the right region is closed: no Changes editor, no Project tree toggles
201
+ // the right region is closed: no Changes editor, no Project tree toggles, no gutter
193
202
  expect(setup.captureCharFrame()).not.toContain("store.ts");
194
203
  expect(diffToggleColumn(setup)).toBe(-1);
195
204
 
196
- // the collapsed rail shows the outline (off) variant; its toggle reopens the region
205
+ // the reopen toggle (outline/off variant) rides the thread header; clicking it reopens the region
197
206
  await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
198
207
  await waitForText(setup, "store.ts");
199
208
  expect(diffToggleColumn(setup)).toBeGreaterThan(0);
@@ -271,7 +280,7 @@ describe("the four-pane workbench", () => {
271
280
  /** The bare-launch (no thread) shell is the same four panes as a thread: the Thread pane waits in its
272
281
  * empty state and a disposable Welcome tab rides in the Changes editor. The right-region toggles behave
273
282
  * like the thread shell, dismissing Welcome collapses the editor without stranding an empty screen, and
274
- * the collapsed rail draws its divider in the header only. */
283
+ * a closed right region moves its reopen toggle into the header rather than leaving a gutter. */
275
284
  describe("the bare-launch welcome shell", () => {
276
285
  let welcomeHome: string;
277
286
  let welcomeRepo: string;
@@ -313,6 +322,38 @@ describe("the bare-launch welcome shell", () => {
313
322
  expect(setup.captureCharFrame()).toContain("README.md");
314
323
  });
315
324
 
325
+ test("the first comment in the bare shell creates and persists a per-repo workbench thread", async () => {
326
+ const setup = await renderWelcome();
327
+
328
+ // open a project file in the bare shell (no thread yet - nothing on disk)
329
+ expect(welcomeServer.core.sessionList().length).toBe(0);
330
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
331
+ await waitForText(setup, "README.md");
332
+ const readme = locateText(setup, "README.md");
333
+ await setup.mockMouse.click(readme.column, readme.row);
334
+ await waitForText(setup, "edited in the working tree");
335
+
336
+ // comment on a line - the first note find-or-creates the workbench thread
337
+ const line = "edited in the working tree";
338
+ await dragText(setup, line, line, line.length);
339
+ await typeText(setup, "workbench note");
340
+ await pressKey(setup, "RETURN", { meta: true });
341
+
342
+ // the daemon persisted one per-repo workbench thread carrying the note
343
+ await waitForState(setup, () =>
344
+ welcomeServer.core.sessionList().some((thread) => thread.artifact.meta.workbench === true),
345
+ );
346
+ const workbench = welcomeServer.core
347
+ .sessionList()
348
+ .find((thread) => thread.artifact.meta.workbench === true)!;
349
+ expect(workbench.workspace.rootCommit).toBeTruthy();
350
+ await waitForState(setup, () =>
351
+ welcomeServer.core
352
+ .sessionGet(workbench.id)
353
+ .annotations.some((note) => note.body.includes("workbench note")),
354
+ );
355
+ });
356
+
316
357
  test("the changes tree lists the launch repo's working-tree changes", async () => {
317
358
  const setup = await renderWelcome();
318
359
 
@@ -324,12 +365,42 @@ describe("the bare-launch welcome shell", () => {
324
365
  test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
325
366
  const setup = await renderWelcome();
326
367
 
368
+ // the Welcome playground measures its width before it paints, so wait for its copy
369
+ await waitForText(setup, "Getting started");
327
370
  const frame = setup.captureCharFrame();
328
371
  // the getting-started surface is an editor tab, not the thread pane
329
372
  expect(frame.split("\n")[HEADER_ROW]!).toContain("Welcome");
330
- expect(frame).toContain("Welcome to cueloop");
373
+ expect(frame).toContain("Getting started");
331
374
  // the Thread pane shows its empty state until a thread is opened
332
- expect(frame).toContain("Select a thread");
375
+ expect(frame).toContain("no threads");
376
+ });
377
+
378
+ test("typing in the welcome composer never reaches the inbox keys", async () => {
379
+ // a pending thread in the sidebar so the inbox keys (delete, open) have a target to act on
380
+ welcomeServer.core.sessionCreate({
381
+ workspace: { repoRoot: welcomeRepo, branch: "main" },
382
+ artifact: { type: "plan", content: "# Seeded\n", meta: { title: "Seeded Plan" } },
383
+ });
384
+ const setup = await testRender(
385
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
386
+ { width: 160, height: 28 },
387
+ );
388
+ await waitForText(setup, "quick brown fox");
389
+
390
+ // open the playground composer, then type letters that are also inbox keys (j, k, d), one at a
391
+ // time as a person would - each keystroke settles a frame, the way real input arrives
392
+ await dragText(setup, "quick brown fox", "quick brown fox", "quick brown fox".length);
393
+ for (const character of "note: jkd") await typeText(setup, character);
394
+ await waitForText(setup, "note: jkd");
395
+
396
+ // the letters landed in the note, not the inbox: no delete confirmation opened
397
+ expect(setup.captureCharFrame()).not.toContain("This removes the plan");
398
+
399
+ // Cmd+Enter saves the note; it must not dispatch openSession and replace the playground
400
+ await pressKey(setup, "RETURN", { meta: true });
401
+ expect(setup.captureCharFrame()).toContain("Getting started");
402
+
403
+ setup.renderer.destroy();
333
404
  });
334
405
 
335
406
  test("dismissing the Welcome tab collapses the editor without stranding an empty screen", async () => {
@@ -340,13 +411,13 @@ describe("the bare-launch welcome shell", () => {
340
411
  await setup.mockMouse.click(welcome.column + 8, welcome.row);
341
412
  await waitForState(
342
413
  setup,
343
- () => !setup.captureCharFrame().includes("review loop for coding agents"),
414
+ () => !setup.captureCharFrame().includes("comment on agent-authored work"),
344
415
  );
345
416
 
346
417
  // the Threads sidebar and the Thread empty state remain - never a blank shell
347
418
  const frame = setup.captureCharFrame();
348
419
  expect(frame).toContain("cueloop");
349
- expect(frame).toContain("Select a thread");
420
+ expect(frame).toContain("no threads");
350
421
  });
351
422
 
352
423
  test("the changed-files and tree toggles switch navigator mode without collapsing", async () => {
@@ -364,20 +435,20 @@ describe("the bare-launch welcome shell", () => {
364
435
  await waitForState(setup, () => !setup.captureCharFrame().includes("src"));
365
436
  expect(diffToggleColumn(setup)).toBeGreaterThan(0);
366
437
 
367
- // the sidebar toggle collapses the region to a rail
438
+ // the sidebar toggle closes the region
368
439
  await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
369
440
  await waitForState(setup, () => diffToggleColumn(setup) === -1);
370
441
  });
371
442
 
372
- test("the collapsed rail draws its divider in the header only, not full-height", async () => {
443
+ test("a closed region draws its reopen divider in the header only, not full-height", async () => {
373
444
  const setup = await renderWelcome();
374
445
 
375
- // collapse the right region so only the rail remains
446
+ // close the right region so only the header toggle remains
376
447
  await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
377
448
  await waitForState(setup, () => diffToggleColumn(setup) === -1);
378
449
 
379
450
  const lines = setup.captureCharFrame().split("\n");
380
- // the rail's divider sits at the far right; find its column on the header row
451
+ // the reopen toggle's divider sits at the far right; find its column on the header row
381
452
  const headerDivider = lines[HEADER_ROW]!.lastIndexOf("│");
382
453
  expect(headerDivider).toBeGreaterThan(0);
383
454
  // that column stays clear below the two-row header - no full-height column rule