@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
@@ -197,3 +197,122 @@ describe("live working-tree diff for a non-diff thread", () => {
197
197
  controller.close();
198
198
  });
199
199
  });
200
+
201
+ const STALE_PATCH = `diff --git a/src/stale.ts b/src/stale.ts
202
+ --- a/src/stale.ts
203
+ +++ b/src/stale.ts
204
+ @@ -1,1 +1,1 @@
205
+ -const s = 1;
206
+ +const s = 2;
207
+ `;
208
+
209
+ const STALE_FILES = [
210
+ {
211
+ path: "src/stale.ts",
212
+ oldContents: "const s = 1;\n",
213
+ newContents: "const s = 2;\n",
214
+ status: "modified" as const,
215
+ },
216
+ ];
217
+
218
+ /** A `type:"diff"` thread with a captured snapshot; `meta.workbench` decides frozen vs live. */
219
+ function diffSession(meta: Thread["artifact"]["meta"], id = "ses_diff"): Thread {
220
+ return {
221
+ schemaVersion: SCHEMA_VERSION,
222
+ id,
223
+ workspace: { repoRoot: "/repo", branch: "main" },
224
+ artifact: { type: "diff", content: STALE_PATCH, files: STALE_FILES, meta },
225
+ revisions: [{ revision: 1, content: STALE_PATCH, submittedAt: AT }],
226
+ annotations: [],
227
+ verdict: null,
228
+ status: "pending",
229
+ createdAt: AT,
230
+ };
231
+ }
232
+
233
+ describe("frozen vs live diff by thread kind", () => {
234
+ test("a workbench thread renders the live working tree, not its captured snapshot", async () => {
235
+ const session = diffSession({ workbench: true, title: "Workbench" });
236
+ const controller = createReviewController({
237
+ sessionId: session.id,
238
+ openClient: async () => fakeClient(session),
239
+ shareTransport,
240
+ });
241
+ controller.connect();
242
+ await tick();
243
+
244
+ // the live repoDiff (src/x.ts) wins over the frozen capture (src/stale.ts)
245
+ const changes = await controller.repoChanges();
246
+
247
+ expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
248
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
249
+ expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(false);
250
+
251
+ controller.close();
252
+ });
253
+
254
+ test("a plain diff review pins its captured snapshot and never queries the live tree", async () => {
255
+ const session = diffSession({});
256
+ const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
257
+ const client = { ...fakeClient(session), repoDiff } satisfies SessionClient;
258
+ const controller = createReviewController({
259
+ sessionId: session.id,
260
+ openClient: async () => client,
261
+ shareTransport,
262
+ });
263
+ controller.connect();
264
+ await tick();
265
+
266
+ const changes = await controller.repoChanges();
267
+
268
+ expect(changes.map((file) => file.path)).toEqual(["src/stale.ts"]);
269
+ expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(true);
270
+ expect(repoDiff).not.toHaveBeenCalled();
271
+
272
+ controller.close();
273
+ });
274
+ });
275
+
276
+ const SERVED_PATCH = `diff --git a/src/served.ts b/src/served.ts
277
+ --- a/src/served.ts
278
+ +++ b/src/served.ts
279
+ @@ -1,1 +1,1 @@
280
+ -const served = 0;
281
+ +const served = 1;
282
+ `;
283
+
284
+ describe("serve mode pins the served thread to a frozen snapshot", () => {
285
+ test("the served artifact wins over the thread's own capture and the live tree", async () => {
286
+ const session = diffSession({ workbench: true });
287
+ const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
288
+ const client = { ...fakeClient(session), repoDiff } satisfies SessionClient;
289
+ const controller = createReviewController({
290
+ sessionId: session.id,
291
+ openClient: async () => client,
292
+ shareTransport,
293
+ servedArtifact: {
294
+ type: "diff",
295
+ content: SERVED_PATCH,
296
+ files: [
297
+ {
298
+ path: "src/served.ts",
299
+ oldContents: "const served = 0;\n",
300
+ newContents: "const served = 1;\n",
301
+ status: "modified",
302
+ },
303
+ ],
304
+ meta: { workbench: true, snapshot: true },
305
+ },
306
+ });
307
+ controller.connect();
308
+ await tick();
309
+
310
+ // the observer sees the frozen served snapshot, not the thread's stale capture nor the live tree
311
+ expect(controller.rows().some((row) => row.file === "src/served.ts")).toBe(true);
312
+ expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(false);
313
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
314
+ expect(repoDiff).not.toHaveBeenCalled();
315
+
316
+ controller.close();
317
+ });
318
+ });
@@ -29,6 +29,7 @@ import {
29
29
  type Anchor,
30
30
  type Annotation,
31
31
  type AnnotationTarget,
32
+ type Artifact,
32
33
  type DiffFileContents,
33
34
  type Thread,
34
35
  type SessionHistory,
@@ -46,8 +47,16 @@ import {
46
47
  } from "./share";
47
48
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
48
49
  import { entryTarget, treeRows, type TreeRow } from "./tree-view";
49
- import { diffRowBlocks, diffRows, fileChangeCounts, fileRowRange, type DiffRow } from "./view-diff";
50
+ import {
51
+ diffRowBlocks,
52
+ diffRows,
53
+ fileChangeCounts,
54
+ fileRowRange,
55
+ readsFrozenDiff,
56
+ type DiffRow,
57
+ } from "./view-diff";
50
58
  import { applyFold } from "./diff-fold";
59
+ import { snapshotWorkbench } from "./workbench-snapshot";
51
60
  import { copyToClipboard } from "./clipboard";
52
61
  import {
53
62
  changeRejectionForRow,
@@ -180,6 +189,8 @@ export interface ReviewControllerOptions {
180
189
  */
181
190
  openClient?: () => Promise<SessionClient>;
182
191
  shareTransport?: ShareTransport;
192
+ /** Serve mode: pin this frozen diff onto the served thread so an observer sees a stable snapshot. */
193
+ servedArtifact?: Artifact;
183
194
  }
184
195
 
185
196
  export interface ReviewController {
@@ -268,6 +279,14 @@ export interface ReviewController {
268
279
  * first write a browse-only launch makes - nothing is on disk until it runs.
269
280
  */
270
281
  commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
282
+ /** As commentOnWorkbench, for a note drawn on the bare-launch Changes diff (row coords, file target). */
283
+ commentOnWorkbenchDiff(
284
+ displayIndex: number,
285
+ start: number,
286
+ end: number,
287
+ endDisplayIndex: number,
288
+ body: string,
289
+ ): Promise<void>;
271
290
  /**
272
291
  * Reply to `rootAnnotationId`: the reply shares the root's anchor and names
273
292
  * it in replyTo, so the discussion stays one conversation. Returns the minted id.
@@ -395,10 +414,19 @@ class Controller implements ReviewController {
395
414
  getSnapshot = (): ControllerSnapshot => this.snapshot;
396
415
 
397
416
  private update(patch: Partial<ControllerSnapshot>): void {
398
- this.snapshot = { ...this.snapshot, ...patch };
417
+ this.snapshot = { ...this.snapshot, ...this.freezeServed(patch) };
399
418
  for (const listener of this.listeners) listener();
400
419
  }
401
420
 
421
+ /** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
422
+ private freezeServed(patch: Partial<ControllerSnapshot>): Partial<ControllerSnapshot> {
423
+ const served = this.options.servedArtifact;
424
+
425
+ if (!served || !patch.session || patch.session.id !== this.options.sessionId) return patch;
426
+
427
+ return { ...patch, session: { ...patch.session, artifact: served } };
428
+ }
429
+
402
430
  connect(): void {
403
431
  void (async () => {
404
432
  try {
@@ -470,9 +498,9 @@ class Controller implements ReviewController {
470
498
  // ── derived projections ─────────────────────
471
499
  private ensureDerived(): void {
472
500
  const session = this.snapshot.session;
473
- const isDiff = session?.artifact.type === "diff";
474
- // a diff thread reads its pinned capture; every other thread reflects the live working tree
475
- const liveDiff = isDiff ? null : this.liveDiff;
501
+ const frozenDiff = readsFrozenDiff(session);
502
+ // a plain diff thread reads its pinned capture; a workbench thread and every other thread reflect the live working tree
503
+ const liveDiff = frozenDiff ? null : this.liveDiff;
476
504
 
477
505
  if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
478
506
  // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
@@ -483,8 +511,8 @@ class Controller implements ReviewController {
483
511
  this.derivedFor = session;
484
512
  this.derivedForLiveDiff = liveDiff;
485
513
 
486
- const content = isDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
487
- const files = isDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
514
+ const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
515
+ const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
488
516
  const rows = content ? diffRows(content) : [];
489
517
  const models = new Map<string, FileDiffMetadata>();
490
518
 
@@ -502,7 +530,7 @@ class Controller implements ReviewController {
502
530
  private foldFiles(): readonly DiffFileContents[] | undefined {
503
531
  const session = this.snapshot.session;
504
532
 
505
- return session?.artifact.type === "diff" ? session.artifact.files : this.liveDiff?.files;
533
+ return readsFrozenDiff(session) ? session!.artifact.files : this.liveDiff?.files;
506
534
  }
507
535
 
508
536
  treeRows(): TreeRow[] {
@@ -614,9 +642,9 @@ class Controller implements ReviewController {
614
642
  }
615
643
 
616
644
  async repoChanges(): Promise<readonly DiffFileContents[]> {
617
- // a diff review pins its captured snapshot; every other thread reflects the live working tree
645
+ // a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
618
646
  const session = this.snapshot.session;
619
- if (session?.artifact.type === "diff") return session.artifact.files ?? [];
647
+ if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
620
648
  // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
621
649
  if (this.client?.repoDiff !== undefined) {
622
650
  const diff = await this.client.repoDiff(this.sidebarRepoRoot());
@@ -1073,26 +1101,49 @@ class Controller implements ReviewController {
1073
1101
  return wire.id;
1074
1102
  }
1075
1103
 
1076
- async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1077
- if (!this.snapshot.session) {
1078
- if (this.client?.sessionWorkbench === undefined) return;
1079
- let workbench;
1104
+ /** Find-or-create the per-repo workbench thread and adopt it as active; true once a session exists. */
1105
+ private async ensureWorkbenchSession(): Promise<boolean> {
1106
+ if (this.snapshot.session) return true;
1107
+ if (this.client?.sessionWorkbench === undefined) return false;
1108
+ let workbench;
1080
1109
 
1081
- try {
1082
- workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
1083
- } catch (cause) {
1084
- // the composer already closed, so a lost first comment must at least surface, not vanish
1085
- this.setStatus(String(cause instanceof Error ? cause.message : cause));
1110
+ try {
1111
+ workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
1112
+ } catch (cause) {
1113
+ // the composer already closed, so a lost first comment must at least surface, not vanish
1114
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
1086
1115
 
1087
- return;
1088
- }
1089
- // adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
1090
- this.update({ session: workbench });
1091
- void this.refreshInbox();
1116
+ return false;
1092
1117
  }
1118
+ // adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
1119
+ this.update({ session: workbench });
1120
+ void this.refreshInbox();
1121
+
1122
+ return true;
1123
+ }
1124
+
1125
+ async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1126
+ if (!(await this.ensureWorkbenchSession())) return;
1093
1127
  this.addComment(anchor, target, body);
1094
1128
  }
1095
1129
 
1130
+ /** A first comment on the bare-launch Changes diff: promote to the workbench thread, then anchor it in the diff rows. */
1131
+ async commentOnWorkbenchDiff(
1132
+ displayIndex: number,
1133
+ start: number,
1134
+ end: number,
1135
+ endDisplayIndex: number,
1136
+ body: string,
1137
+ ): Promise<void> {
1138
+ if (!(await this.ensureWorkbenchSession())) return;
1139
+ // annotate re-derives the file path and rev from the diff row it lands on
1140
+ this.annotate("comment", displayIndex, start, end, body, endDisplayIndex, {
1141
+ kind: "file",
1142
+ path: "",
1143
+ rev: "worktree",
1144
+ });
1145
+ }
1146
+
1096
1147
  reply(rootAnnotationId: string, body: string): string | undefined {
1097
1148
  const session = this.snapshot.session;
1098
1149
  const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
@@ -1270,13 +1321,22 @@ class Controller implements ReviewController {
1270
1321
  );
1271
1322
  }
1272
1323
 
1324
+ /** A workbench thread renders live locally; freeze its diff so a remote reviewer gets a stable snapshot. */
1325
+ private freezeForShare(session: Thread): Promise<Thread> {
1326
+ const repoDiff = this.client?.repoDiff;
1327
+
1328
+ return repoDiff
1329
+ ? snapshotWorkbench(session, (root) => repoDiff.call(this.client, root))
1330
+ : Promise.resolve(session);
1331
+ }
1332
+
1273
1333
  share(): void {
1274
1334
  const session = this.snapshot.session;
1275
1335
 
1276
1336
  if (!session) return;
1277
1337
  this.setStatus("sharing…");
1278
- this.shareTransport
1279
- .publish(session)
1338
+ this.freezeForShare(session)
1339
+ .then((shared) => this.shareTransport.publish(shared))
1280
1340
  .then(async ({ line, copied }) => {
1281
1341
  // Stamp the id back so a later pull knows which share to collect from.
1282
1342
  const shareId = this.shareTransport.parseShareId(line);
@@ -1373,7 +1433,8 @@ class Controller implements ReviewController {
1373
1433
  client
1374
1434
  .sessionFork(session.id)
1375
1435
  .then(async (fork) => {
1376
- const { line, copied } = await this.shareTransport.publish(fork);
1436
+ const shared = await this.freezeForShare(fork);
1437
+ const { line, copied } = await this.shareTransport.publish(shared);
1377
1438
  const shareId = this.shareTransport.parseShareId(line);
1378
1439
 
1379
1440
  if (shareId) await client.sessionSetShareId(fork.id, shareId);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useRef, useState } from "react";
6
6
  import type { ProjectPanelMode } from "./components/AppShell";
7
+ import type { LaunchLayout } from "./launch-layout";
7
8
  import {
8
9
  activateTab,
9
10
  addTab,
@@ -22,6 +23,41 @@ import {
22
23
  /** Seed the workbench for a bare launch: the region opens on a disposable Welcome tab. */
23
24
  export interface ChangesWorkbenchOptions {
24
25
  seed?: "changes" | "welcome";
26
+ /** The launch layout the right region opens in; overrides the seed's defaults. */
27
+ layout?: LaunchLayout;
28
+ }
29
+
30
+ interface InitialPanes {
31
+ projectOpen: boolean;
32
+ changesOpen: boolean;
33
+ projectMode: ProjectPanelMode;
34
+ zoomed: boolean;
35
+ }
36
+
37
+ /** The right region's opening state: from the launch layout when given, else the welcome-seed defaults. */
38
+ function initialPanes(options?: ChangesWorkbenchOptions): InitialPanes {
39
+ const layout = options?.layout;
40
+
41
+ if (layout) {
42
+ const changesOpen = layout.rightSidebar === "changes";
43
+
44
+ return {
45
+ projectOpen: layout.rightSidebar !== "off",
46
+ changesOpen,
47
+ projectMode: layout.rightSidebar === "project" ? "tree" : "changes",
48
+ // zoom fills the middle with the Changes editor, so a hand-edited layout that zooms without one
49
+ // would strand a blank center with the Thread pane hidden; only honor zoom when Changes is open
50
+ zoomed: layout.zoomChanges && changesOpen,
51
+ };
52
+ }
53
+ const welcomeSeed = options?.seed === "welcome";
54
+
55
+ return {
56
+ projectOpen: welcomeSeed,
57
+ changesOpen: welcomeSeed,
58
+ projectMode: "changes",
59
+ zoomed: false,
60
+ };
25
61
  }
26
62
 
27
63
  export interface ChangesWorkbench {
@@ -46,10 +82,11 @@ export interface ChangesWorkbench {
46
82
 
47
83
  export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesWorkbench {
48
84
  const welcomeSeed = options?.seed === "welcome";
49
- const [projectOpen, setProjectOpen] = useState(welcomeSeed);
50
- const [changesOpen, setChangesOpen] = useState(welcomeSeed);
51
- const [projectMode, setProjectMode] = useState<ProjectPanelMode>("changes");
52
- const [zoomed, setZoomed] = useState(false);
85
+ const initial = initialPanes(options);
86
+ const [projectOpen, setProjectOpen] = useState(initial.projectOpen);
87
+ const [changesOpen, setChangesOpen] = useState(initial.changesOpen);
88
+ const [projectMode, setProjectMode] = useState<ProjectPanelMode>(initial.projectMode);
89
+ const [zoomed, setZoomed] = useState(initial.zoomed);
53
90
  const [grid, setGrid] = useState<EditorNode>(() =>
54
91
  makeGroup([welcomeSeed ? welcomeTab() : changesTab()]),
55
92
  );
@@ -0,0 +1,31 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { persistLayout } from "./config";
3
+ import { layoutFromPanes, type LaunchLayout } from "./launch-layout";
4
+
5
+ /**
6
+ * Persist the pane composition whenever the user changes it, so the next bare launch restores it.
7
+ * Only a launch that carried an intended layout (a create-command or the vanilla inbox) remembers;
8
+ * a bare thread open collapses the sidebar and must not clobber the remembered composition. The
9
+ * mount write is skipped: the opening composition is the one just restored.
10
+ */
11
+ export function useRememberLayout(
12
+ layout: LaunchLayout | undefined,
13
+ active: boolean,
14
+ threads: boolean,
15
+ changesOpen: boolean,
16
+ projectOpen: boolean,
17
+ zoomChanges: boolean,
18
+ ): void {
19
+ const settled = useRef(false);
20
+
21
+ useEffect(() => {
22
+ if (!settled.current) {
23
+ settled.current = true;
24
+
25
+ return;
26
+ }
27
+ // only the active workbench persists: on a bare launch App's unused panes must not clobber the shell's
28
+ if (!active || layout === undefined) return;
29
+ persistLayout(layoutFromPanes({ threads, changesOpen, projectOpen, zoomChanges }));
30
+ }, [active, layout, threads, changesOpen, projectOpen, zoomChanges]);
31
+ }
@@ -111,6 +111,15 @@ export function useSettingsDialog(params: {
111
111
  setQuickActions(next);
112
112
  persistActions(next);
113
113
  };
114
+ const editActionPrompt = (index: number, prompt: string): void => {
115
+ // a blank title fails the config schema and would vanish on reload, so never persist one
116
+ if (prompt.trim().length === 0) return;
117
+ commitActions(
118
+ quickActions.map((action, actionIndex) =>
119
+ actionIndex === index ? { ...action, prompt } : action,
120
+ ),
121
+ );
122
+ };
114
123
  const editActionMetadata = (index: number, metadata: string): void =>
115
124
  commitActions(
116
125
  quickActions.map((action, actionIndex) =>
@@ -175,6 +184,7 @@ export function useSettingsDialog(params: {
175
184
  setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
176
185
  setActionsExpandedIndex((current) => (current === index ? null : index));
177
186
  }}
187
+ onEditPrompt={editActionPrompt}
178
188
  onEditMetadata={editActionMetadata}
179
189
  onReset={resetActions}
180
190
  onAdd={addAction}
@@ -215,8 +225,7 @@ export function useSettingsDialog(params: {
215
225
  };
216
226
 
217
227
  const onCategorySelect = (categoryId: string): void => {
218
- // the nav tree carries a synthetic Settings group folder; clicking it must
219
- // not become the active category, or the key handler dereferences nothing
228
+ // ignore a select that names no real category, so the key handler never dereferences nothing
220
229
  if (!settingsCategories.some((category) => category.id === categoryId)) return;
221
230
  setActionsExpandedIndex(null);
222
231
  setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { makeAnchor, type Annotation } from "@cueloop/schema";
3
- import { diffRowAnchor, diffRowBlocks, diffRows, fileRowRange, fileTargetMarks } from "./view-diff";
2
+ import { makeAnchor, type Annotation, type Thread } from "@cueloop/schema";
3
+ import {
4
+ changesMarks,
5
+ diffRowAnchor,
6
+ diffRowBlocks,
7
+ diffRows,
8
+ fileRowRange,
9
+ fileTargetMarks,
10
+ } from "./view-diff";
4
11
 
5
12
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
6
13
  index 111..222 100644
@@ -110,3 +117,67 @@ diff --git a/b.ts b/b.ts
110
117
  expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
111
118
  });
112
119
  });
120
+
121
+ describe("changesMarks", () => {
122
+ const PATCH = `diff --git a/x.ts b/x.ts
123
+ --- a/x.ts
124
+ +++ b/x.ts
125
+ @@ -1,1 +1,2 @@
126
+ const keep = 0;
127
+ +const added = 1;
128
+ `;
129
+
130
+ function diffThread(meta: Thread["artifact"]["meta"], annotations: Annotation[]): Thread {
131
+ return {
132
+ schemaVersion: "1",
133
+ id: "s_changes",
134
+ workspace: { repoRoot: "/repo", branch: "main" },
135
+ artifact: { type: "diff", content: PATCH, meta },
136
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
137
+ annotations,
138
+ verdict: null,
139
+ status: "pending",
140
+ createdAt: "2026-01-01T00:00:00Z",
141
+ };
142
+ }
143
+
144
+ function fileNote(rows: ReturnType<typeof diffRows>): Annotation {
145
+ const range = fileRowRange(rows, "x.ts")!;
146
+ const fileRows = rows.slice(range.start, range.end);
147
+ const addRelative = fileRows.findIndex((row) => row.kind === "add");
148
+ const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
149
+ const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
150
+
151
+ return {
152
+ id: "f1",
153
+ kind: "comment",
154
+ anchor,
155
+ target: { kind: "file", path: "x.ts", rev: "worktree" },
156
+ body: "note",
157
+ createdAt: "2026-01-01T00:00:00Z",
158
+ };
159
+ }
160
+
161
+ test("a workbench thread paints its file-targeted working-tree notes", () => {
162
+ const rows = diffRows(PATCH);
163
+ const session = diffThread({ workbench: true }, [fileNote(rows)]);
164
+
165
+ expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
166
+ });
167
+
168
+ test("a pinned diff review paints only artifact notes, not the working tree's file targets", () => {
169
+ const rows = diffRows(PATCH);
170
+ const session = diffThread({}, [fileNote(rows)]);
171
+
172
+ // the same file-targeted note is not an artifact note, so a frozen review leaves it unpainted
173
+ expect([...changesMarks(session, rows).keys()].length).toBe(0);
174
+ });
175
+
176
+ test("a shared snapshot still paints the workbench's file-targeted feedback", () => {
177
+ const rows = diffRows(PATCH);
178
+ // a served/shared snapshot keeps the workbench marker, so its notes stay file-targeted and visible
179
+ const session = diffThread({ workbench: true, snapshot: true }, [fileNote(rows)]);
180
+
181
+ expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
182
+ });
183
+ });
package/src/view-diff.ts CHANGED
@@ -248,6 +248,44 @@ export function fileTargetMarks(
248
248
  return result;
249
249
  }
250
250
 
251
+ /** A plain diff review's notes anchor to the artifact itself; a working-tree diff's notes target files. */
252
+ export function annotatesArtifact(session: Thread | null): boolean {
253
+ return session?.artifact.type === "diff" && session.artifact.meta.workbench !== true;
254
+ }
255
+
256
+ /**
257
+ * Render the diff from the pinned artifact rather than the live working tree: a plain diff review (the
258
+ * artifact is the diff), or a shared/served snapshot of a workbench thread (the remote has no tree).
259
+ */
260
+ export function readsFrozenDiff(session: Thread | null): boolean {
261
+ return (
262
+ session?.artifact.type === "diff" &&
263
+ (session.artifact.meta.workbench !== true || session.artifact.meta.snapshot === true)
264
+ );
265
+ }
266
+
267
+ /**
268
+ * The marks a Changes surface paints. A plain diff review is the artifact itself, so only its
269
+ * artifact-anchored notes belong; a workbench thread (live or a frozen snapshot) and every other thread
270
+ * show a working-tree diff, whose notes carry a file target and resolve per file - so a refresh never
271
+ * rebinds one across files, and a snapshot still surfaces the feedback already on the thread.
272
+ */
273
+ export function changesMarks(
274
+ session: Thread,
275
+ rows: DiffRow[],
276
+ focusedId?: string,
277
+ ): Map<number, Mark[]> {
278
+ if (annotatesArtifact(session)) {
279
+ const artifactNotes = session.annotations.filter(
280
+ (annotation) => annotationTarget(annotation).kind === "artifact",
281
+ );
282
+
283
+ return marksByRows(artifactNotes, rows, focusedId);
284
+ }
285
+
286
+ return fileTargetMarks(session.annotations, rows, focusedId);
287
+ }
288
+
251
289
  /**
252
290
  * Comments per file path across the diff: each discussion counts its comments (root plus
253
291
  * replies) toward the file its span ends in. Feeds the changed-files tree and tab badges, so a
@@ -0,0 +1,81 @@
1
+ /** Sharing/serving a workbench thread freezes its live working-tree diff into the artifact so a remote
2
+ * reviewer, who cannot see the owner's tree, reads a stable snapshot; any other thread passes through. */
3
+
4
+ import { describe, expect, mock, test } from "bun:test";
5
+ import { historyFromLinear, viewFollowing, type Thread } from "@cueloop/schema";
6
+ import { snapshotWorkbench } from "./workbench-snapshot";
7
+
8
+ const AT = "2026-01-01T00:00:00.000Z";
9
+
10
+ const STALE_FILES = [
11
+ { path: "src/a.ts", oldContents: "a\n", newContents: "b\n", status: "modified" as const },
12
+ ];
13
+ const LIVE = {
14
+ patch: "FRESH PATCH",
15
+ files: [
16
+ { path: "src/a.ts", oldContents: "a\n", newContents: "c\n", status: "modified" as const },
17
+ ],
18
+ };
19
+
20
+ function diffThread(meta: Thread["artifact"]["meta"]): Thread {
21
+ return {
22
+ schemaVersion: "1",
23
+ id: "ses_wb",
24
+ workspace: { repoRoot: "/repo", branch: "main" },
25
+ artifact: { type: "diff", content: "STALE PATCH", files: STALE_FILES, meta },
26
+ revisions: [{ revision: 1, content: "STALE PATCH", submittedAt: AT }],
27
+ annotations: [
28
+ {
29
+ id: "n1",
30
+ kind: "comment",
31
+ anchor: { quote: "b", prefix: "", suffix: "" },
32
+ body: "why?",
33
+ createdAt: AT,
34
+ },
35
+ ],
36
+ verdict: null,
37
+ status: "pending",
38
+ createdAt: AT,
39
+ };
40
+ }
41
+
42
+ describe("snapshotWorkbench", () => {
43
+ test("freezes a workbench thread's live diff and drops the workbench flag", async () => {
44
+ const frozen = await snapshotWorkbench(
45
+ diffThread({ workbench: true, title: "Workbench" }),
46
+ async () => LIVE,
47
+ );
48
+
49
+ // the live diff is pinned into the artifact
50
+ expect(frozen.artifact.content).toBe("FRESH PATCH");
51
+ expect(frozen.artifact.files).toEqual(LIVE.files);
52
+ // the snapshot marker pins the render; the workbench marker stays so file-targeted feedback still paints
53
+ expect(frozen.artifact.meta.snapshot).toBe(true);
54
+ expect(frozen.artifact.meta.workbench).toBe(true);
55
+ expect(frozen.artifact.meta.title).toBe("Workbench");
56
+ // the thread is otherwise intact - same id, workspace, and annotations
57
+ expect(frozen.id).toBe("ses_wb");
58
+ expect(frozen.annotations).toHaveLength(1);
59
+ });
60
+
61
+ test("recaptures history so the share path rebuilds the fresh content, not the stale head", async () => {
62
+ // a workbench thread carries branch history whose head is the creation-time capture
63
+ const base = diffThread({ workbench: true });
64
+ const withHistory: Thread = { ...base, history: historyFromLinear(base) };
65
+
66
+ const frozen = await snapshotWorkbench(withHistory, async () => LIVE);
67
+
68
+ // viewFollowing (the share path) rebuilds content from history - it must yield the fresh patch
69
+ expect(viewFollowing(frozen).artifact.content).toBe("FRESH PATCH");
70
+ });
71
+
72
+ test("passes a plain diff review through untouched and never queries the tree", async () => {
73
+ const session = diffThread({ title: "PR #7" });
74
+ const repoDiff = mock(async () => LIVE);
75
+
76
+ const result = await snapshotWorkbench(session, repoDiff);
77
+
78
+ expect(result).toBe(session);
79
+ expect(repoDiff).not.toHaveBeenCalled();
80
+ });
81
+ });