@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65

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 (68) hide show
  1. package/package.json +7 -6
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
@@ -1,10 +1,15 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
2
2
  import { ManualClock } from "@opentui/core/testing";
3
3
  import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
4
4
  import type { SessionClient } from "@cueloop/daemon/client";
5
+ import {
6
+ createReviewController,
7
+ SHARE_RECONNECT_MAX_MS,
8
+ SHARE_RECONNECT_MIN_MS,
9
+ type ShareTransport,
10
+ } from "./session-controller";
11
+ import { mergeFromShare, type ShareWatchHandlers } from "./share";
5
12
 
6
- // Stub the ssh transport so no subprocess spawns; the controller under test
7
- // imports these from "./share".
8
13
  const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
9
14
  let remote: ReviewSession;
10
15
  const pullShare = mock(async () => remote);
@@ -12,16 +17,15 @@ const pushShare = mock(
12
17
  async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
13
18
  );
14
19
 
15
- mock.module("./share", () => ({
16
- publishShare,
17
- pullShare,
18
- pushShare,
19
- shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
20
- collaboratorAnnotations: (session: ReviewSession) =>
21
- session.annotations.filter((annotation) => annotation.author),
22
- }));
23
-
24
- const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
20
+ const shareTransport: ShareTransport = {
21
+ publish: publishShare,
22
+ pull: pullShare,
23
+ push: pushShare,
24
+ watch: () => () => {},
25
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
26
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
27
+ mergeFromShare,
28
+ };
25
29
 
26
30
  function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
27
31
  return {
@@ -49,13 +53,31 @@ function annotation(id: string, author: string): Annotation {
49
53
  };
50
54
  }
51
55
 
52
- function fakeClient(session: ReviewSession): SessionClient {
56
+ interface FakeSessionClient extends SessionClient {
57
+ sessionAnnotate: Mock<SessionClient["sessionAnnotate"]>;
58
+ }
59
+
60
+ const unimplemented = (member: string) => () =>
61
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
62
+
63
+ function fakeClient(session: ReviewSession): FakeSessionClient {
53
64
  return {
54
65
  onEvent: () => () => {},
55
66
  subscribe: async () => {},
56
67
  sessionGet: async () => session,
57
68
  sessionList: async () => [session],
58
- sessionAnnotate: mock(async () => session),
69
+ sessionAnnotate: mock<SessionClient["sessionAnnotate"]>(async () => session),
70
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
71
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
72
+ sessionCutBlock: unimplemented("sessionCutBlock"),
73
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
74
+ sessionCurate: unimplemented("sessionCurate"),
75
+ sessionNavigate: unimplemented("sessionNavigate"),
76
+ sessionBranch: unimplemented("sessionBranch"),
77
+ sessionSwitch: unimplemented("sessionSwitch"),
78
+ sessionLabel: unimplemented("sessionLabel"),
79
+ sessionFork: unimplemented("sessionFork"),
80
+ sessionSetViewed: unimplemented("sessionSetViewed"),
59
81
  sessionSetShareId: mock(
60
82
  async (_id: string, shareId: string) => ((session.shareId = shareId), session),
61
83
  ),
@@ -67,8 +89,11 @@ function fakeClient(session: ReviewSession): SessionClient {
67
89
 
68
90
  return session;
69
91
  }),
92
+ sessionDelete: unimplemented("sessionDelete"),
93
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
94
+ sessionResolve: unimplemented("sessionResolve"),
70
95
  close: () => {},
71
- } as unknown as SessionClient;
96
+ };
72
97
  }
73
98
 
74
99
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -76,12 +101,14 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
76
101
  async function connectedController(
77
102
  session: ReviewSession,
78
103
  clock?: ManualClock,
79
- ): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
104
+ transport: ShareTransport = shareTransport,
105
+ ): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
80
106
  const client = fakeClient(session);
81
107
  const controller = createReviewController({
82
108
  sessionId: session.id,
83
109
  openClient: async () => client,
84
110
  clock,
111
+ shareTransport: transport,
85
112
  });
86
113
 
87
114
  controller.connect();
@@ -205,7 +232,7 @@ describe("mirror on annotate", () => {
205
232
  sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
206
233
  );
207
234
 
208
- (client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
235
+ client.sessionAnnotate.mockImplementationOnce(async () => {
209
236
  throw new Error("session is resolved");
210
237
  });
211
238
 
@@ -218,103 +245,226 @@ describe("mirror on annotate", () => {
218
245
  });
219
246
  });
220
247
 
221
- describe("startSharePoll", () => {
222
- test("pulls now and again each interval until stopped", async () => {
248
+ describe("reply", () => {
249
+ test("a reply shares the root's anchor, names it in replyTo, and mirrors up", async () => {
223
250
  // Arrange
224
- pullShare.mockClear();
225
- remote = sessionFixture({ annotations: [] });
226
- const clock = new ManualClock();
227
- const { controller } = await connectedController(
228
- sessionFixture({ shareId: "p_abc123xy" }),
229
- clock,
251
+ pushShare.mockClear();
252
+ const { controller, client } = await connectedController(
253
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
230
254
  );
231
255
 
232
- // Act - immediate pull
233
- const stop = controller.startSharePoll();
234
-
256
+ // Act
257
+ const id = controller.reply("a1", "agreed");
235
258
  await tick();
236
259
 
237
260
  // Assert
238
- expect(pullShare).toHaveBeenCalledTimes(1);
261
+ const wire = client.sessionAnnotate.mock.calls.at(-1)![1];
262
+
263
+ expect(wire).toEqual({
264
+ id: id!,
265
+ kind: "comment",
266
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
267
+ body: "agreed",
268
+ replyTo: "a1",
269
+ });
270
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([wire]);
271
+ });
239
272
 
240
- // Act - one interval later, it pulls again
241
- clock.advance(SHARE_POLL_MS);
273
+ test("editing a reply keeps its reply link", async () => {
274
+ // Arrange
275
+ const { controller, client } = await connectedController(
276
+ sessionFixture({
277
+ annotations: [
278
+ annotation("a1", "SHA256:ana"),
279
+ { ...annotation("a2", "SHA256:me"), replyTo: "a1" },
280
+ ],
281
+ }),
282
+ );
283
+
284
+ // Act
285
+ controller.updateAnnotation("a2", "revised reply");
242
286
  await tick();
243
287
 
244
288
  // Assert
245
- expect(pullShare).toHaveBeenCalledTimes(2);
289
+ expect(client.sessionAnnotate.mock.calls.at(-1)?.[1]).toMatchObject({
290
+ id: "a2",
291
+ body: "revised reply",
292
+ replyTo: "a1",
293
+ });
294
+ });
246
295
 
247
- // Act - stop, then advance: no further pulls
248
- stop();
249
- clock.advance(SHARE_POLL_MS * 3);
296
+ test("a reply to a reply hangs off the discussion's root comment", async () => {
297
+ // Arrange
298
+ const { controller, client } = await connectedController(
299
+ sessionFixture({
300
+ annotations: [
301
+ annotation("a1", "SHA256:ana"),
302
+ { ...annotation("a2", "SHA256:bob"), replyTo: "a1" },
303
+ ],
304
+ }),
305
+ );
306
+
307
+ // Act
308
+ controller.reply("a2", "same thread");
250
309
  await tick();
251
310
 
252
311
  // Assert
253
- expect(pullShare).toHaveBeenCalledTimes(2);
312
+ expect(client.sessionAnnotate.mock.calls.at(-1)?.[1]).toMatchObject({ replyTo: "a1" });
313
+ });
314
+
315
+ test("replying to an unknown annotation does nothing", async () => {
316
+ // Arrange
317
+ const { controller, client } = await connectedController(sessionFixture());
318
+
319
+ // Act + Assert
320
+ expect(controller.reply("missing", "x")).toBeUndefined();
321
+ expect(client.sessionAnnotate).not.toHaveBeenCalled();
322
+ });
323
+ });
324
+
325
+ describe("startShareSync", () => {
326
+ /** A fake watch stream: records handlers so a test can push a session or drop the link. */
327
+ const streams: Array<{ shareId: string; handlers: ShareWatchHandlers; stopped: boolean }> = [];
328
+ const watchShare = mock((shareId: string, handlers: ShareWatchHandlers) => {
329
+ const stream = { shareId, handlers, stopped: false };
330
+
331
+ streams.push(stream);
332
+
333
+ return () => {
334
+ stream.stopped = true;
335
+ };
254
336
  });
337
+ const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
255
338
 
256
- test("a stop during an in-flight pull leaves no zombie timer", async () => {
257
- // Arrange - the first pull hangs until released
339
+ beforeEach(() => {
340
+ streams.length = 0;
341
+ watchShare.mockClear();
258
342
  pullShare.mockClear();
259
343
  remote = sessionFixture({ annotations: [] });
260
- let release: () => void = () => {};
344
+ });
345
+
346
+ test("connect pulls once to catch up, then follows the stream and merges each session it delivers", async () => {
347
+ // Arrange
348
+ const clock = new ManualClock();
349
+ const { controller, client } = await connectedController(
350
+ sessionFixture({ shareId: "p_abc123xy" }),
351
+ clock,
352
+ liveTransport,
353
+ );
354
+
355
+ // Act
356
+ controller.startShareSync();
357
+ await tick();
261
358
 
262
- pullShare.mockImplementationOnce(
263
- () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
359
+ // Assert - one catch-up pull, one open stream on the share
360
+ expect(pullShare).toHaveBeenCalledTimes(1);
361
+ expect(streams).toHaveLength(1);
362
+ expect(streams[0]!.shareId).toBe("p_abc123xy");
363
+
364
+ // Act - the gateway pushes a change carrying Ana's note
365
+ streams[0]!.handlers.onSession(
366
+ sessionFixture({ annotations: [annotation("a9", "SHA256:ana")] }),
367
+ );
368
+ await tick();
369
+
370
+ // Assert - merged without another pull
371
+ expect(client.sessionMergeShared).toHaveBeenLastCalledWith(
372
+ "ses_1",
373
+ expect.objectContaining({
374
+ annotations: [expect.objectContaining({ id: "a9", author: "SHA256:ana" })],
375
+ }),
264
376
  );
377
+ expect(pullShare).toHaveBeenCalledTimes(1);
378
+ });
379
+
380
+ test("a dropped stream reconnects with doubling backoff and catches up on each connect", async () => {
381
+ // Arrange
265
382
  const clock = new ManualClock();
266
383
  const { controller } = await connectedController(
267
384
  sessionFixture({ shareId: "p_abc123xy" }),
268
385
  clock,
386
+ liveTransport,
269
387
  );
270
388
 
271
- // Act - start (pull is in flight), leave, then let the pull settle
272
- const stop = controller.startSharePoll();
389
+ controller.startShareSync();
390
+ await tick();
273
391
 
392
+ // Act - the link drops: nothing until the floor delay has passed
393
+ streams[0]!.handlers.onClose("ssh exited 255");
394
+ clock.advance(SHARE_RECONNECT_MIN_MS - 1);
274
395
  await tick();
275
- expect(pullShare).toHaveBeenCalledTimes(1);
276
- stop();
277
- release();
396
+ expect(streams).toHaveLength(1);
397
+ clock.advance(1);
278
398
  await tick();
279
- clock.advance(SHARE_POLL_MS * 3);
399
+
400
+ // Assert - reconnected, with a fresh catch-up pull
401
+ expect(streams).toHaveLength(2);
402
+ expect(pullShare).toHaveBeenCalledTimes(2);
403
+
404
+ // Act - drops again: the wait doubles
405
+ streams[1]!.handlers.onClose("ssh exited 255");
406
+ clock.advance(SHARE_RECONNECT_MIN_MS);
280
407
  await tick();
408
+ expect(streams).toHaveLength(2);
409
+ clock.advance(SHARE_RECONNECT_MIN_MS);
410
+ await tick();
411
+ expect(streams).toHaveLength(3);
281
412
 
282
- // Assert - the settled pull's finally must not re-arm the timer
283
- expect(pullShare).toHaveBeenCalledTimes(1);
284
- });
413
+ // Act - a delivered session resets the backoff to the floor
414
+ streams[2]!.handlers.onSession(remote);
415
+ streams[2]!.handlers.onClose("ssh exited 255");
416
+ clock.advance(SHARE_RECONNECT_MIN_MS);
417
+ await tick();
285
418
 
286
- test("a restart during an in-flight pull does not double the poll", async () => {
287
- // Arrange - run A's first pull hangs; later pulls resolve immediately
288
- pullShare.mockClear();
289
- remote = sessionFixture({ annotations: [] });
290
- let release: () => void = () => {};
419
+ // Assert
420
+ expect(streams).toHaveLength(4);
421
+ });
291
422
 
292
- pullShare.mockImplementationOnce(
293
- () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
294
- );
423
+ test("stop closes the stream and a late close never reconnects", async () => {
424
+ // Arrange
295
425
  const clock = new ManualClock();
296
426
  const { controller } = await connectedController(
297
427
  sessionFixture({ shareId: "p_abc123xy" }),
298
428
  clock,
429
+ liveTransport,
299
430
  );
431
+ const stop = controller.startShareSync();
300
432
 
301
- // Act - run A starts (pull hangs), then run B restarts while A is in flight
302
- controller.startSharePoll();
303
433
  await tick();
304
- expect(pullShare).toHaveBeenCalledTimes(1);
305
- const stopB = controller.startSharePoll();
306
434
 
435
+ // Act
436
+ stop();
437
+ streams[0]!.handlers.onClose("killed");
438
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
307
439
  await tick();
308
- expect(pullShare).toHaveBeenCalledTimes(2);
309
440
 
310
- // A's stale pull settles - it must not re-arm; then one interval fires only run B
311
- release();
441
+ // Assert
442
+ expect(streams[0]!.stopped).toBe(true);
443
+ expect(streams).toHaveLength(1);
444
+ });
445
+
446
+ test("a restart replaces the stream instead of doubling it", async () => {
447
+ // Arrange
448
+ const clock = new ManualClock();
449
+ const { controller } = await connectedController(
450
+ sessionFixture({ shareId: "p_abc123xy" }),
451
+ clock,
452
+ liveTransport,
453
+ );
454
+
455
+ controller.startShareSync();
456
+ await tick();
457
+
458
+ // Act
459
+ controller.startShareSync();
312
460
  await tick();
313
- clock.advance(SHARE_POLL_MS);
461
+ streams[0]!.handlers.onClose("stale");
462
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
314
463
  await tick();
315
464
 
316
- // Assert - exactly one more pull (run B), not two
317
- expect(pullShare).toHaveBeenCalledTimes(3);
318
- stopB();
465
+ // Assert - the first stream was stopped and its close re-armed nothing
466
+ expect(streams[0]!.stopped).toBe(true);
467
+ expect(streams).toHaveLength(2);
468
+ expect(streams[1]!.stopped).toBe(false);
319
469
  });
320
470
  });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The controller's tree primitives over a fake client: a move shows its path
3
+ * before the daemon answers, refusals surface as status, a fork opens the fork,
4
+ * and fork-and-share shares the fork while the current session stays put.
5
+ */
6
+
7
+ import { describe, expect, mock, test } from "bun:test";
8
+ import {
9
+ appendEntry,
10
+ createBranch,
11
+ historyFromLinear,
12
+ SCHEMA_VERSION,
13
+ switchBranch,
14
+ type Annotation,
15
+ type ReviewSession,
16
+ } from "@cueloop/schema";
17
+ import type { SessionClient } from "@cueloop/daemon/client";
18
+ import { createReviewController, type ShareTransport } from "./session-controller";
19
+ import { mergeFromShare } from "./share";
20
+
21
+ const AT = "2026-01-01T00:00:00.000Z";
22
+
23
+ function annotation(id: string, body: string): Annotation {
24
+ return {
25
+ id,
26
+ kind: "comment",
27
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
28
+ body,
29
+ createdAt: AT,
30
+ };
31
+ }
32
+
33
+ /** A plan with one comment on main and its history. */
34
+ function sessionFixture(): ReviewSession {
35
+ const session: ReviewSession = {
36
+ schemaVersion: SCHEMA_VERSION,
37
+ id: "ses_1",
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
40
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
41
+ annotations: [annotation("a1", "first")],
42
+ verdict: null,
43
+ status: "pending",
44
+ createdAt: AT,
45
+ };
46
+
47
+ session.history = historyFromLinear(session);
48
+
49
+ return session;
50
+ }
51
+
52
+ const publish = mock(async (session: ReviewSession) => ({
53
+ line: `ssh p_${session.id}@cueloop.dev`,
54
+ copied: true,
55
+ }));
56
+ const shareTransport: ShareTransport = {
57
+ publish,
58
+ pull: mock(async () => sessionFixture()),
59
+ push: mock(async () => {}),
60
+ watch: () => () => {},
61
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
62
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
63
+ mergeFromShare,
64
+ };
65
+
66
+ const unimplemented = (member: string) => () =>
67
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
68
+
69
+ /** Tree requests never resolve here: what the controller shows is its own guess. */
70
+ const pending = () => new Promise<ReviewSession>(() => {});
71
+
72
+ function fakeClient(session: ReviewSession) {
73
+ return {
74
+ onEvent: () => () => {},
75
+ subscribe: async () => {},
76
+ sessionGet: async () => session,
77
+ sessionList: async () => [session],
78
+ sessionAnnotate: unimplemented("sessionAnnotate"),
79
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
80
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
81
+ sessionCutBlock: unimplemented("sessionCutBlock"),
82
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
83
+ sessionCurate: unimplemented("sessionCurate"),
84
+ sessionNavigate: mock(pending),
85
+ sessionBranch: mock(pending),
86
+ sessionSwitch: mock(pending),
87
+ sessionLabel: mock(pending),
88
+ sessionFork: mock(async (id: string) => ({
89
+ ...sessionFixture(),
90
+ id: `${id}_fork`,
91
+ parentSessionId: id,
92
+ })),
93
+ sessionSetViewed: unimplemented("sessionSetViewed"),
94
+ sessionSetShareId: mock(async (_id: string, _shareId: string) => session),
95
+ sessionMergeShared: unimplemented("sessionMergeShared"),
96
+ sessionDelete: unimplemented("sessionDelete"),
97
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
98
+ sessionResolve: unimplemented("sessionResolve"),
99
+ close: () => {},
100
+ } satisfies SessionClient;
101
+ }
102
+
103
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
104
+
105
+ async function connected(session = sessionFixture()) {
106
+ const client = fakeClient(session);
107
+ const controller = createReviewController({
108
+ sessionId: session.id,
109
+ openClient: async () => client,
110
+ shareTransport,
111
+ });
112
+
113
+ controller.connect();
114
+ await tick();
115
+
116
+ return { controller, client, session };
117
+ }
118
+
119
+ describe("tree primitives", () => {
120
+ test("a move back shows the earlier path at once and asks the daemon for the same move", async () => {
121
+ // Arrange
122
+ const { controller, client, session } = await connected();
123
+ const revision = session.history!.entries[0]!.id;
124
+
125
+ // Act
126
+ controller.goToEntry(revision, "too soon");
127
+
128
+ // Assert: the comment is off the shown path before any answer; the summary travels
129
+ expect(controller.getSnapshot().session!.annotations).toEqual([]);
130
+ expect(controller.getSnapshot().session!.shelvedAnnotations?.map((entry) => entry.id)).toEqual([
131
+ "a1",
132
+ ]);
133
+ expect(controller.treeRows().find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
134
+ expect(client.sessionNavigate).toHaveBeenCalledWith("ses_1", revision, "too soon", undefined);
135
+ });
136
+
137
+ test("a move on another branch is one request that names the branch", async () => {
138
+ // Arrange: alt with its own comment entry; the owner stands on main
139
+ const session = sessionFixture();
140
+ let history = createBranch(session.history!, "alt");
141
+
142
+ history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
143
+ history = appendEntry(history, { type: "comment", annotationId: "b2", createdAt: AT }).history;
144
+ session.history = switchBranch(history, "main");
145
+ session.shelvedAnnotations = [annotation("b1", "alt one"), annotation("b2", "alt two")];
146
+ const { controller, client } = await connected(session);
147
+ const altFirst = history.entries.find(
148
+ (entry) => entry.type === "comment" && entry.annotationId === "b1",
149
+ )!;
150
+
151
+ // Act
152
+ controller.goToEntry(altFirst.id);
153
+
154
+ // Assert
155
+ expect(client.sessionSwitch).not.toHaveBeenCalled();
156
+ expect(client.sessionNavigate).toHaveBeenCalledWith("ses_1", altFirst.id, undefined, "alt");
157
+ expect(controller.getSnapshot().session!.annotations.map((entry) => entry.id)).toEqual([
158
+ "a1",
159
+ "b1",
160
+ ]);
161
+ });
162
+
163
+ test("a refused move gives way to the daemon's record", async () => {
164
+ // Arrange: the daemon refuses; a re-read hands back the untouched record
165
+ const session = sessionFixture();
166
+ const { controller, client } = await connected(session);
167
+
168
+ client.sessionNavigate.mockImplementationOnce(() => Promise.reject(new Error("resolved")));
169
+
170
+ // Act
171
+ controller.goToEntry(session.history!.entries[0]!.id);
172
+ const guessed = controller.getSnapshot().session!.annotations.length;
173
+
174
+ await tick();
175
+
176
+ // Assert: the guess showed, then the record came back
177
+ expect(guessed).toBe(0);
178
+ expect(controller.getSnapshot().session!.annotations.map((entry) => entry.id)).toEqual(["a1"]);
179
+ expect(controller.getSnapshot().status).toBe("resolved");
180
+ });
181
+
182
+ test("branch and label refuse an empty name and a taken branch name", async () => {
183
+ // Arrange
184
+ const { controller, client } = await connected();
185
+
186
+ // Act
187
+ controller.branch(" ");
188
+ controller.branch("main");
189
+ controller.labelTip("");
190
+
191
+ // Assert
192
+ expect(client.sessionBranch).not.toHaveBeenCalled();
193
+ expect(client.sessionLabel).not.toHaveBeenCalled();
194
+ expect(controller.getSnapshot().status).toBe("a checkpoint needs a name");
195
+ });
196
+
197
+ test("a branch shows up on the tip and becomes the current branch", async () => {
198
+ // Arrange
199
+ const { controller, client } = await connected();
200
+
201
+ // Act
202
+ controller.branch("alt");
203
+
204
+ // Assert
205
+ expect(controller.getSnapshot().session!.history!.branch).toBe("alt");
206
+ expect(controller.treeRows().find((row) => row.isCurrentTip)?.tips).toEqual(["main", "alt"]);
207
+ expect(client.sessionBranch).toHaveBeenCalledWith("ses_1", "alt");
208
+ });
209
+
210
+ test("fork opens the fork; fork-and-share shares the fork and stays on the original", async () => {
211
+ // Arrange
212
+ const forked = await connected();
213
+ const shared = await connected();
214
+
215
+ // Act
216
+ forked.controller.fork();
217
+ shared.controller.forkAndShare();
218
+ await tick();
219
+ await tick();
220
+
221
+ // Assert
222
+ expect(forked.controller.getSnapshot().session!.id).toBe("ses_1_fork");
223
+ expect(shared.controller.getSnapshot().session!.id).toBe("ses_1");
224
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_1_fork" }));
225
+ expect(shared.client.sessionSetShareId).toHaveBeenCalledWith("ses_1_fork", "p_ses_1_fork");
226
+ expect(shared.controller.getSnapshot().toast?.title).toBe("fork shared - link copied");
227
+ });
228
+ });