@cueloop/client 0.1.0-alpha.92 → 0.1.0-alpha.94

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 +10 -10
  2. package/src/App.deleted-scroll.test.tsx +186 -0
  3. package/src/App.diff.test.tsx +3 -4
  4. package/src/App.test.tsx +1 -0
  5. package/src/App.toast.test.tsx +27 -1
  6. package/src/App.tsx +52 -12
  7. package/src/App.workbench.test.tsx +290 -2
  8. package/src/app-screens.tsx +34 -8
  9. package/src/components/AppShell.zoom.test.tsx +16 -3
  10. package/src/components/ChangesColumn.tsx +10 -4
  11. package/src/components/ChangesFileTree.test.tsx +122 -4
  12. package/src/components/DiffContentView.scroll.test.tsx +285 -1
  13. package/src/components/DiffContentView.tsx +214 -56
  14. package/src/components/EditorGrid.split.test.tsx +118 -0
  15. package/src/components/EditorGrid.stories.tsx +27 -0
  16. package/src/components/EditorGrid.tsx +170 -50
  17. package/src/components/EditorGrid.zoom.test.tsx +65 -1
  18. package/src/components/GridTabContent.actions.test.tsx +151 -0
  19. package/src/components/GridTabContent.stories.tsx +2 -2
  20. package/src/components/GridTabContent.tsx +5 -1
  21. package/src/components/ProjectTreeView.test.tsx +166 -8
  22. package/src/components/ProjectTreeView.tsx +53 -27
  23. package/src/components/ScrollArea.tsx +21 -2
  24. package/src/components/Toast.tsx +3 -1
  25. package/src/components/WelcomePlayground.test.tsx +28 -1
  26. package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
  27. package/src/components/editor-grid.test.ts +87 -0
  28. package/src/components/editor-grid.ts +142 -6
  29. package/src/components/primitives/Tree.tsx +51 -15
  30. package/src/run.ts +6 -0
  31. package/src/thread-controller.curate.test.ts +27 -0
  32. package/src/thread-controller.live-diff.test.ts +30 -0
  33. package/src/thread-controller.ts +29 -7
  34. package/src/use-annotation-surface.tsx +86 -19
  35. package/src/use-changes-workbench.ts +30 -18
  36. package/src/use-frame-measure.ts +40 -7
  37. package/src/use-repo-changes.ts +5 -1
  38. package/src/use-settings-dialog.tsx +1 -13
  39. package/src/use-shared-keyboard.ts +55 -0
  40. package/src/use-terminal-virtualizer.ts +8 -2
@@ -3,10 +3,14 @@ import {
3
3
  addTab,
4
4
  changesTab,
5
5
  closeTab,
6
+ countEditorGroups,
6
7
  containsGroup,
7
8
  fileTab,
8
9
  firstGroupId,
10
+ keepFileTab,
9
11
  makeGroup,
12
+ MAX_EDITOR_GROUPS,
13
+ openFileTab,
10
14
  splitGroup,
11
15
  type EditorBranch,
12
16
  type EditorGroup,
@@ -38,6 +42,42 @@ describe("editor grid", () => {
38
42
  expect(next.activeTabId).toBe(file.id);
39
43
  });
40
44
 
45
+ test("a new preview replaces the group's previous preview", () => {
46
+ const group = makeGroup([changesTab()]);
47
+ const first = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
48
+ const second = asGroup(openFileTab(first, group.id, "src/b.ts", "contents", false).tree);
49
+
50
+ expect(second.tabs.map((tab) => tab.path)).toEqual([undefined, "src/b.ts"]);
51
+ expect(second.tabs[1]?.preview).toBe(true);
52
+ });
53
+
54
+ test("keeping a preview makes subsequent previews open beside it", () => {
55
+ const group = makeGroup([changesTab()]);
56
+ const preview = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
57
+ const kept = asGroup(keepFileTab(preview, group.id, preview.activeTabId!));
58
+ const next = asGroup(openFileTab(kept, group.id, "src/b.ts", "contents", false).tree);
59
+
60
+ expect(next.tabs.map((tab) => tab.path)).toEqual([undefined, "src/a.ts", "src/b.ts"]);
61
+ expect(next.tabs[1]?.preview).toBe(false);
62
+ });
63
+
64
+ test("reopening a file focuses its existing tab across groups and changes its view", () => {
65
+ const group = makeGroup([changesTab()]);
66
+ const open = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", true).tree);
67
+ const { tree } = splitGroup(open, group.id, "right");
68
+ const branch = asBranch(tree);
69
+ const right = asGroup(branch.children[1]!);
70
+ const reopened = openFileTab(tree, group.id, "src/a.ts", "diff", false);
71
+
72
+ expect(reopened.focusGroupId).toBe(right.id);
73
+ expect(asGroup(asBranch(reopened.tree).children[1]!).tabs).toHaveLength(1);
74
+ expect(asGroup(asBranch(reopened.tree).children[1]!).tabs[0]).toMatchObject({
75
+ path: "src/a.ts",
76
+ fileView: "diff",
77
+ preview: false,
78
+ });
79
+ });
80
+
41
81
  test("splitting right makes a horizontal branch and focuses the new group", () => {
42
82
  const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
43
83
  const { tree, focusGroupId } = splitGroup(group, group.id, "right");
@@ -48,6 +88,7 @@ describe("editor grid", () => {
48
88
  // the new group is the second child (right edge) and takes focus
49
89
  expect(branch.children[1]!.id).toBe(focusGroupId);
50
90
  expect(firstGroupId(tree)).toBe(group.id);
91
+ expect(asGroup(branch.children[0]!).tabs.some((tab) => tab.path === "src/App.tsx")).toBe(false);
51
92
  });
52
93
 
53
94
  test("splitting up makes a vertical branch with the new group first", () => {
@@ -58,6 +99,32 @@ describe("editor grid", () => {
58
99
  expect(branch.children[0]!.id).toBe(focusGroupId);
59
100
  });
60
101
 
102
+ test("splitting a file-only group keeps the Changes tab unique", () => {
103
+ const changes = makeGroup([changesTab()]);
104
+ const file = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
105
+ const tree: EditorNode = {
106
+ type: "branch",
107
+ id: "two-groups",
108
+ orientation: "horizontal",
109
+ children: [changes, file],
110
+ };
111
+ const split = splitGroup(tree, file.id, "down");
112
+
113
+ expect(asBranch(split.tree).children[0]).toBe(changes);
114
+ expect(asBranch(asBranch(split.tree).children[1]!).children[0]).toMatchObject({
115
+ tabs: [{ kind: "welcome" }],
116
+ });
117
+ });
118
+
119
+ test("splitting Changes moves it to the new group", () => {
120
+ const group = makeGroup([changesTab()]);
121
+ const split = splitGroup(group, group.id, "right");
122
+ const branch = asBranch(split.tree);
123
+
124
+ expect(asGroup(branch.children[0]!).tabs[0]?.kind).toBe("welcome");
125
+ expect(asGroup(branch.children[1]!).tabs[0]?.kind).toBe("changes");
126
+ });
127
+
61
128
  test("closing the last tab of a split collapses the branch back to one group", () => {
62
129
  const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
63
130
  const { tree } = splitGroup(group, group.id, "right");
@@ -83,4 +150,24 @@ describe("editor grid", () => {
83
150
  const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
84
151
  expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
85
152
  });
153
+
154
+ test("mixed split directions stop at eight editor groups", () => {
155
+ const first = makeGroup([changesTab()]);
156
+ let tree: EditorNode = first;
157
+ let focusGroupId = first.id;
158
+ const directions = ["right", "up", "left", "down"] as const;
159
+
160
+ for (const direction of [...directions, ...directions]) {
161
+ const result = splitGroup(tree, focusGroupId, direction);
162
+
163
+ tree = result.tree;
164
+ focusGroupId = result.focusGroupId;
165
+ }
166
+
167
+ expect(countEditorGroups(tree)).toBe(MAX_EDITOR_GROUPS);
168
+ const excess = splitGroup(tree, focusGroupId, "right");
169
+
170
+ expect(excess.tree).toBe(tree);
171
+ expect(excess.focusGroupId).toBe(focusGroupId);
172
+ });
86
173
  });
@@ -1,6 +1,6 @@
1
1
  // The Changes pane's editor layout: a tree of nodes where a leaf is an editor group (a tab strip plus
2
- // its one active tab) and a branch splits child nodes along an orientation. Splitting an editor group
3
- // moves its active tab into a new group; a group is pruned when its last tab closes, collapsing the
2
+ // its one active tab) and a branch splits child nodes along an orientation. Splitting a file tab
3
+ // moves it into a new group; a group is pruned when its last tab closes, collapsing the
4
4
  // branch that held it.
5
5
 
6
6
  /** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
@@ -12,6 +12,8 @@ export interface EditorTab {
12
12
  path?: string;
13
13
  /** A file tab shows either a single-file diff or the file's plain contents. */
14
14
  fileView?: "diff" | "contents";
15
+ /** A preview is replaced by the next file opened in its group. */
16
+ preview?: boolean;
15
17
  }
16
18
 
17
19
  /** A leaf of the grid: one editor group holding tabs, with the active tab id. */
@@ -35,6 +37,9 @@ export type EditorNode = EditorGroup | EditorBranch;
35
37
  /** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
36
38
  export type SplitDirection = "left" | "right" | "up" | "down";
37
39
 
40
+ /** The editor grid stops at eight groups so every tile remains usable. */
41
+ export const MAX_EDITOR_GROUPS = 8;
42
+
38
43
  /** A split's outcome: the new grid and the id of the group that should take focus. */
39
44
  export interface SplitResult {
40
45
  tree: EditorNode;
@@ -59,8 +64,13 @@ export function welcomeTab(): EditorTab {
59
64
  }
60
65
 
61
66
  /** A file tab, opened from a tree; a diff view for a changed file, plain contents otherwise. */
62
- export function fileTab(label: string, path: string, fileView: "diff" | "contents"): EditorTab {
63
- return { id: freshId(), kind: "file", label, path, fileView };
67
+ export function fileTab(
68
+ label: string,
69
+ path: string,
70
+ fileView: "diff" | "contents",
71
+ preview = false,
72
+ ): EditorTab {
73
+ return { id: freshId(), kind: "file", label, path, fileView, preview };
64
74
  }
65
75
 
66
76
  /** The id of the first editor group in reading order; the fallback focus target. */
@@ -74,6 +84,13 @@ export function containsGroup(node: EditorNode, groupId: string): boolean {
74
84
  return node.children.some((child) => containsGroup(child, groupId));
75
85
  }
76
86
 
87
+ /** Count editor tiles, including groups inside nested horizontal and vertical splits. */
88
+ export function countEditorGroups(node: EditorNode): number {
89
+ if (node.type === "group") return 1;
90
+
91
+ return node.children.reduce((count, child) => count + countEditorGroups(child), 0);
92
+ }
93
+
77
94
  function replaceGroup(
78
95
  node: EditorNode,
79
96
  id: string,
@@ -97,6 +114,111 @@ export function addTab(node: EditorNode, groupId: string, tab: EditorTab): Edito
97
114
  }));
98
115
  }
99
116
 
117
+ /** Activate the one Changes tab, or add it beside the active group's existing tabs. */
118
+ export function openChangesTab(node: EditorNode, groupId: string): SplitResult {
119
+ const existing = findTabByKind(node, "changes");
120
+
121
+ if (existing) {
122
+ return {
123
+ tree: activateTab(node, existing.groupId, existing.tab.id),
124
+ focusGroupId: existing.groupId,
125
+ };
126
+ }
127
+
128
+ return { tree: addTab(node, groupId, changesTab()), focusGroupId: groupId };
129
+ }
130
+
131
+ function findTabByKind(
132
+ node: EditorNode,
133
+ kind: EditorTab["kind"],
134
+ ): { groupId: string; tab: EditorTab } | null {
135
+ if (node.type === "group") {
136
+ const tab = node.tabs.find((candidate) => candidate.kind === kind);
137
+
138
+ return tab ? { groupId: node.id, tab } : null;
139
+ }
140
+
141
+ for (const child of node.children) {
142
+ const found = findTabByKind(child, kind);
143
+
144
+ if (found) return found;
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ interface FileTabLocation {
151
+ groupId: string;
152
+ tab: EditorTab;
153
+ }
154
+
155
+ /** A file path is one editor tab across the grid, even when the groups are split. */
156
+ function findFileTab(node: EditorNode, path: string): FileTabLocation | null {
157
+ if (node.type === "group") {
158
+ const tab = node.tabs.find((candidate) => candidate.kind === "file" && candidate.path === path);
159
+
160
+ return tab ? { groupId: node.id, tab } : null;
161
+ }
162
+
163
+ for (const child of node.children) {
164
+ const found = findFileTab(child, path);
165
+
166
+ if (found) return found;
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ /** Open a file once, replacing only a preview; reopening focuses its existing tab. */
173
+ export function openFileTab(
174
+ node: EditorNode,
175
+ groupId: string,
176
+ path: string,
177
+ fileView: "diff" | "contents",
178
+ persistent: boolean,
179
+ ): SplitResult {
180
+ const existing = findFileTab(node, path);
181
+
182
+ if (existing) {
183
+ return {
184
+ tree: replaceGroup(node, existing.groupId, (group) => ({
185
+ ...group,
186
+ tabs: group.tabs.map((tab) =>
187
+ tab.id === existing.tab.id
188
+ ? { ...tab, fileView, preview: persistent ? false : tab.preview }
189
+ : tab,
190
+ ),
191
+ activeTabId: existing.tab.id,
192
+ })),
193
+ focusGroupId: existing.groupId,
194
+ };
195
+ }
196
+
197
+ const label = path.split("/").pop() ?? path;
198
+ const tab = fileTab(label, path, fileView, !persistent);
199
+
200
+ return {
201
+ tree: replaceGroup(node, groupId, (group) => {
202
+ const previewIndex = persistent ? -1 : group.tabs.findIndex((item) => item.preview);
203
+ const tabs = [...group.tabs];
204
+
205
+ if (previewIndex >= 0) tabs.splice(previewIndex, 1, tab);
206
+ else tabs.push(tab);
207
+
208
+ return { ...group, tabs, activeTabId: tab.id };
209
+ }),
210
+ focusGroupId: groupId,
211
+ };
212
+ }
213
+
214
+ /** Keep a preview file tab open when its tab is double-clicked. */
215
+ export function keepFileTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
216
+ return replaceGroup(node, groupId, (group) => ({
217
+ ...group,
218
+ tabs: group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)),
219
+ }));
220
+ }
221
+
100
222
  /** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
101
223
  export function pruneEmpty(node: EditorNode): EditorNode | null {
102
224
  if (node.type === "group") return node.tabs.length > 0 ? node : null;
@@ -122,16 +244,30 @@ export function splitGroup(
122
244
  groupId: string,
123
245
  direction: SplitDirection,
124
246
  ): SplitResult {
247
+ if (countEditorGroups(node) >= MAX_EDITOR_GROUPS) return { tree: node, focusGroupId: groupId };
248
+
125
249
  let focusGroupId = groupId;
126
250
  const tree = replaceGroup(node, groupId, (group) => {
127
251
  const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
128
252
  if (active === undefined) return group;
129
- const movedTab: EditorTab = { ...active, id: freshId() };
253
+ const movedTab: EditorTab =
254
+ active.kind === "welcome" ? { ...active, id: freshId() } : { ...active, preview: false };
130
255
  const newGroup = makeGroup([movedTab]);
131
256
  focusGroupId = newGroup.id;
132
257
  const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
133
258
  const newFirst = direction === "left" || direction === "up";
134
- const children = newFirst ? [newGroup, group] : [group, newGroup];
259
+ const remainingTabs =
260
+ active.kind === "welcome" ? group.tabs : group.tabs.filter((tab) => tab.id !== active.id);
261
+ const sourceTabs =
262
+ remainingTabs.length > 0
263
+ ? remainingTabs
264
+ : [findTabByKind(node, "changes") ? welcomeTab() : changesTab()];
265
+ const sourceGroup = {
266
+ ...group,
267
+ tabs: sourceTabs,
268
+ activeTabId: sourceTabs[0]!.id,
269
+ };
270
+ const children = newFirst ? [newGroup, sourceGroup] : [sourceGroup, newGroup];
135
271
  return { type: "branch", id: freshId(), orientation, children };
136
272
  });
137
273
  return { tree, focusGroupId };
@@ -1,7 +1,8 @@
1
1
  // OpenTUI renderer for the headless tree-model; the caller owns expansion and selection.
2
2
 
3
- import React from "react";
3
+ import React, { useRef } from "react";
4
4
  import { DARK, type Theme } from "../../theme";
5
+ import { isDoubleClick, type ClickStamp } from "../../thread-selection";
5
6
  import { flattenTree, statusMeta, type TreeNode, type TreeTone } from "./tree-model";
6
7
  import { NERD_TREE_ICONS, type TreeIcons } from "./icons";
7
8
 
@@ -13,11 +14,14 @@ export interface TreeProps {
13
14
  showStatus?: boolean;
14
15
  /** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
15
16
  hideIcons?: boolean;
17
+ /** Keep file navigator entries to one visual row, truncating long names. */
18
+ singleLine?: boolean;
16
19
  /** The selected row's backdrop; defaults to `elevated` (invisible on an elevated dialog, use `border` there). */
17
20
  selectedBackground?: string;
18
21
  indentWidth?: number;
19
22
  icons?: TreeIcons;
20
23
  onSelect?: (id: string) => void;
24
+ onDoubleSelect?: (id: string) => void;
21
25
  onToggle?: (id: string) => void;
22
26
  theme?: Theme;
23
27
  }
@@ -43,16 +47,19 @@ export function Tree({
43
47
  flattenEmptyDirectories,
44
48
  showStatus,
45
49
  hideIcons,
50
+ singleLine = false,
46
51
  selectedBackground,
47
52
  indentWidth = 2,
48
53
  icons = NERD_TREE_ICONS,
49
54
  onSelect,
55
+ onDoubleSelect,
50
56
  onToggle,
51
57
  theme,
52
58
  }: TreeProps): React.ReactNode {
53
59
  const tokens = theme ?? DARK;
54
60
  const selectedBackdrop = selectedBackground ?? tokens.elevated;
55
61
  const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
62
+ const lastClick = useRef<{ id: string; stamp: ClickStamp } | null>(null);
56
63
 
57
64
  return (
58
65
  <box style={{ flexDirection: "column" }}>
@@ -73,25 +80,54 @@ export function Tree({
73
80
  id={`tree-row-${row.id}`}
74
81
  style={{
75
82
  flexDirection: "row",
83
+ justifyContent: "space-between",
84
+ height: singleLine ? 1 : undefined,
85
+ overflow: singleLine ? "hidden" : undefined,
76
86
  paddingLeft: 1 + row.depth * indentWidth,
77
- paddingRight: 1,
87
+ paddingRight: singleLine && status ? 0 : 1,
78
88
  backgroundColor: selected ? selectedBackdrop : undefined,
79
89
  }}
80
- onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
90
+ onMouseUp={(event) => {
91
+ const stamp = { time: Date.now(), x: event.x, y: event.y };
92
+ const doubleClick =
93
+ lastClick.current?.id === row.id && isDoubleClick(lastClick.current.stamp, stamp);
94
+
95
+ lastClick.current = { id: row.id, stamp };
96
+ if (row.isFolder) onToggle?.(row.id);
97
+ else {
98
+ onSelect?.(row.id);
99
+ if (doubleClick) onDoubleSelect?.(row.id);
100
+ }
101
+ }}
81
102
  >
82
- {hideIcons ? null : (
83
- <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
84
- {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
85
- </text>
86
- )}
87
- <box style={{ flexShrink: 1, minWidth: 0 }}>
88
- <text fg={selected ? tokens.accent : labelColor} truncate>
89
- {row.label}
90
- </text>
103
+ <box
104
+ style={{
105
+ flexDirection: "row",
106
+ flexShrink: 1,
107
+ minWidth: 0,
108
+ overflow: singleLine ? "hidden" : undefined,
109
+ }}
110
+ >
111
+ {hideIcons ? null : (
112
+ <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
113
+ {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
114
+ </text>
115
+ )}
116
+ <box style={{ flexShrink: 1, minWidth: 0 }}>
117
+ <text
118
+ id={`tree-label-${row.id}`}
119
+ fg={selected ? tokens.accent : labelColor}
120
+ wrapMode={singleLine ? "none" : undefined}
121
+ truncate={!singleLine}
122
+ >
123
+ {singleLine ? `${row.label} ` : row.label}
124
+ </text>
125
+ </box>
126
+ </box>
127
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
128
+ {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
129
+ {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
91
130
  </box>
92
- <box style={{ flexGrow: 1 }} />
93
- {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
94
- {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
95
131
  </box>
96
132
  );
97
133
  })}
package/src/run.ts CHANGED
@@ -6,6 +6,8 @@ import { createRoot } from "@opentui/react";
6
6
  import { App } from "./App";
7
7
  import { closePrototypeBrowser } from "./prototype-browser";
8
8
  import type { Appearance } from "./theme-presets";
9
+ import type { DiffFileContents, Thread } from "@cueloop/schema";
10
+ import type { ThreadClient } from "@cueloop/daemon/client";
9
11
  import { loadConfig } from "./config";
10
12
  import { perfMark } from "./perf/perf-timings";
11
13
  import { reportPerfMarks } from "./perf/perf-report";
@@ -17,6 +19,8 @@ import {
17
19
 
18
20
  export interface RunClientOptions {
19
21
  sessionId?: string;
22
+ initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
23
+ initialClient?: ThreadClient;
20
24
  home?: string;
21
25
  /** The layout a create-command opens in; omit to restore the remembered one, then the default. */
22
26
  layout?: LaunchLayout;
@@ -73,6 +77,8 @@ export async function runClient(options: RunClientOptions): Promise<number> {
73
77
  React.createElement(App, {
74
78
  home: options.home,
75
79
  sessionId: options.sessionId,
80
+ initialReview: options.initialReview,
81
+ initialClient: options.initialClient,
76
82
  appearance,
77
83
  layout,
78
84
  extensionRegistry,
@@ -431,6 +431,33 @@ function tailSession(): Thread {
431
431
  }
432
432
 
433
433
  describe("diff file fold", () => {
434
+ test("added and deleted files with a zero-sided hunk retain their expand action", async () => {
435
+ const patch = [
436
+ "diff --git a/src/new.ts b/src/new.ts",
437
+ "--- /dev/null",
438
+ "+++ b/src/new.ts",
439
+ "@@ -0,0 +1,1 @@",
440
+ "+new line",
441
+ "diff --git a/src/gone.ts b/src/gone.ts",
442
+ "--- a/src/gone.ts",
443
+ "+++ /dev/null",
444
+ "@@ -1,1 +0,0 @@",
445
+ "-old line",
446
+ "",
447
+ ].join("\n");
448
+ const files: DiffFileContents[] = [
449
+ { path: "src/new.ts", oldContents: "", newContents: "new line\n", status: "added" },
450
+ { path: "src/gone.ts", oldContents: "old line\n", newContents: "", status: "deleted" },
451
+ ];
452
+ const session = diffSession(files);
453
+ session.artifact.content = patch;
454
+ session.revisions[0]!.content = patch;
455
+ const { controller } = await connected(session);
456
+
457
+ expect(controller.canExpandFile("src/new.ts")).toBe(true);
458
+ expect(controller.canExpandFile("src/gone.ts")).toBe(true);
459
+ });
460
+
434
461
  test("collapse leaves only the file band; expand restores the body", async () => {
435
462
  // Arrange
436
463
  const { controller } = await connected(tailSession());
@@ -97,6 +97,36 @@ function fakeClient(session: Thread): ThreadClient {
97
97
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
98
98
 
99
99
  describe("live working-tree diff for a non-diff thread", () => {
100
+ test("a preconnected workbench accepts a comment before its inbox refresh", async () => {
101
+ const session = planSession();
102
+ const sent: Annotation[] = [];
103
+ const client = {
104
+ ...fakeClient(session),
105
+ sessionComment: async (_id: string, annotation: Annotation) => {
106
+ sent.push(annotation);
107
+
108
+ return { ...session, annotations: sent };
109
+ },
110
+ } satisfies ThreadClient;
111
+ const controller = createReviewController({
112
+ sessionId: session.id,
113
+ initialSession: session,
114
+ initialDiff: { patch: PATCH, files: FILES },
115
+ initialClient: client,
116
+ shareTransport,
117
+ });
118
+
119
+ controller.addComment(
120
+ { quote: "const a = 2;", prefix: "", suffix: "" },
121
+ { kind: "file", path: "src/x.ts", rev: "worktree" },
122
+ "ready frame comment",
123
+ );
124
+
125
+ expect(sent).toHaveLength(1);
126
+ expect(sent[0]?.body).toBe("ready frame comment");
127
+ controller.close();
128
+ });
129
+
100
130
  test("repoChanges carries file contents and rows derive from the patch", async () => {
101
131
  const session = planSession();
102
132
  const controller = createReviewController({
@@ -251,6 +251,11 @@ export interface NewShareLink {
251
251
  export interface ReviewControllerOptions {
252
252
  home?: string;
253
253
  sessionId?: string;
254
+ /** Captured before the UI mounts, so the first review frame has its thread and live diff. */
255
+ initialSession?: Thread;
256
+ initialDiff?: { patch: string; files: DiffFileContents[] };
257
+ /** The owner connection that supplied the first review snapshot. */
258
+ initialClient?: ThreadClient;
254
259
  /** The directory the client launched in; its git repo backs the no-session welcome tree. Defaults to process.cwd(). */
255
260
  cwd?: string;
256
261
  /** Observer mode: stored for the key reducer's read-only gate. */
@@ -293,8 +298,8 @@ export interface ReviewController {
293
298
  isFileExpanded(file: string): boolean;
294
299
  /** Whether the file carries the full contents weaving needs (curatable diffs do). */
295
300
  canExpandFile(file: string): boolean;
296
- /** Copy a file's path to the clipboard; reports the outcome via status. */
297
- copyFilePath(file: string): void;
301
+ /** Copy a file's path to the clipboard; the control reports the outcome. */
302
+ copyFilePath(file: string): Promise<boolean>;
298
303
  /** Per-file added/removed line counts from the base rows (survives collapse). */
299
304
  fileStats(): Map<string, { additions: number; deletions: number }>;
300
305
  /** The walk's step list, derived from the diff rows. */
@@ -491,6 +496,7 @@ class Controller implements ReviewController {
491
496
  private derivedFor: Thread | null = null;
492
497
  /** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
493
498
  private liveDiff: LiveWorkingDiff | null = null;
499
+ private initialDiffAvailable = false;
494
500
  private derivedForLiveDiff: LiveWorkingDiff | null = null;
495
501
  private derived: DerivedSessionProjection = {
496
502
  display: [],
@@ -514,6 +520,12 @@ class Controller implements ReviewController {
514
520
  }
515
521
 
516
522
  constructor(private readonly options: ReviewControllerOptions) {
523
+ this.client = options.initialClient ?? null;
524
+ if (options.initialSession?.id === options.sessionId && options.initialDiff) {
525
+ this.liveDiff = options.initialDiff;
526
+ this.initialDiffAvailable = true;
527
+ this.store.setState({ session: options.initialSession });
528
+ }
517
529
  this.readOnly = options.readOnly ?? false;
518
530
  this.clock = options.clock ?? new SystemClock();
519
531
  this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
@@ -541,7 +553,9 @@ class Controller implements ReviewController {
541
553
  try {
542
554
  const openClient =
543
555
  this.options.openClient ??
544
- (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
556
+ (() =>
557
+ this.options.initialClient ??
558
+ DaemonClient.connect({ home: this.options.home, autostart: true }));
545
559
  const client = await openClient();
546
560
 
547
561
  if (this.closed) return void client.close();
@@ -782,10 +796,8 @@ class Controller implements ReviewController {
782
796
  return (this.foldFiles() ?? []).some((entry) => entry.path === file);
783
797
  }
784
798
 
785
- copyFilePath(file: string): void {
786
- void copyToClipboard(file).then((copied) =>
787
- this.setStatus(copied ? `copied ${file}` : "no clipboard tool available"),
788
- );
799
+ copyFilePath(file: string): Promise<boolean> {
800
+ return copyToClipboard(file);
789
801
  }
790
802
 
791
803
  fileStats(): Map<string, { additions: number; deletions: number }> {
@@ -841,6 +853,9 @@ class Controller implements ReviewController {
841
853
  // a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
842
854
  const session = this.snapshot.session;
843
855
  if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
856
+ const initialFiles = this.consumeInitialDiffFiles(session);
857
+
858
+ if (initialFiles !== null) return initialFiles;
844
859
  // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
845
860
  if (this.client?.repoDiff !== undefined) {
846
861
  const diff = await this.client.repoDiff(
@@ -881,6 +896,13 @@ class Controller implements ReviewController {
881
896
  }));
882
897
  }
883
898
 
899
+ private consumeInitialDiffFiles(session: Thread | null): readonly DiffFileContents[] | null {
900
+ if (!this.initialDiffAvailable) return null;
901
+ this.initialDiffAvailable = false;
902
+
903
+ return session?.id === this.options.sessionId ? (this.liveDiff?.files ?? []) : null;
904
+ }
905
+
884
906
  async refreshDiff(): Promise<void> {
885
907
  const session = this.snapshot.session;
886
908