@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -1,8 +1,14 @@
1
- import { describe, expect, mock, test, type Mock } 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 { createReviewController, SHARE_POLL_MS, type ShareTransport } from "./session-controller";
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";
6
12
 
7
13
  const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
8
14
  let remote: ReviewSession;
@@ -15,8 +21,10 @@ const shareTransport: ShareTransport = {
15
21
  publish: publishShare,
16
22
  pull: pullShare,
17
23
  push: pushShare,
24
+ watch: () => () => {},
18
25
  parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
19
26
  collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
27
+ mergeFromShare,
20
28
  };
21
29
 
22
30
  function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
@@ -61,6 +69,14 @@ function fakeClient(session: ReviewSession): FakeSessionClient {
61
69
  sessionAnnotate: mock<SessionClient["sessionAnnotate"]>(async () => session),
62
70
  sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
63
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"),
64
80
  sessionSetViewed: unimplemented("sessionSetViewed"),
65
81
  sessionSetShareId: mock(
66
82
  async (_id: string, shareId: string) => ((session.shareId = shareId), session),
@@ -85,13 +101,14 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
85
101
  async function connectedController(
86
102
  session: ReviewSession,
87
103
  clock?: ManualClock,
104
+ transport: ShareTransport = shareTransport,
88
105
  ): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
89
106
  const client = fakeClient(session);
90
107
  const controller = createReviewController({
91
108
  sessionId: session.id,
92
109
  openClient: async () => client,
93
110
  clock,
94
- shareTransport,
111
+ shareTransport: transport,
95
112
  });
96
113
 
97
114
  controller.connect();
@@ -228,103 +245,226 @@ describe("mirror on annotate", () => {
228
245
  });
229
246
  });
230
247
 
231
- describe("startSharePoll", () => {
232
- 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 () => {
233
250
  // Arrange
234
- pullShare.mockClear();
235
- remote = sessionFixture({ annotations: [] });
236
- const clock = new ManualClock();
237
- const { controller } = await connectedController(
238
- sessionFixture({ shareId: "p_abc123xy" }),
239
- clock,
251
+ pushShare.mockClear();
252
+ const { controller, client } = await connectedController(
253
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
240
254
  );
241
255
 
242
- // Act - immediate pull
243
- const stop = controller.startSharePoll();
244
-
256
+ // Act
257
+ const id = controller.reply("a1", "agreed");
245
258
  await tick();
246
259
 
247
260
  // Assert
248
- 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
+ });
272
+
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
+ );
249
283
 
250
- // Act - one interval later, it pulls again
251
- clock.advance(SHARE_POLL_MS);
284
+ // Act
285
+ controller.updateAnnotation("a2", "revised reply");
252
286
  await tick();
253
287
 
254
288
  // Assert
255
- 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
+ });
256
295
 
257
- // Act - stop, then advance: no further pulls
258
- stop();
259
- 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");
260
309
  await tick();
261
310
 
262
311
  // Assert
263
- 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();
264
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 };
265
330
 
266
- test("a stop during an in-flight pull leaves no zombie timer", async () => {
267
- // Arrange - the first pull hangs until released
331
+ streams.push(stream);
332
+
333
+ return () => {
334
+ stream.stopped = true;
335
+ };
336
+ });
337
+ const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
338
+
339
+ beforeEach(() => {
340
+ streams.length = 0;
341
+ watchShare.mockClear();
268
342
  pullShare.mockClear();
269
343
  remote = sessionFixture({ annotations: [] });
270
- let release: () => void = () => {};
344
+ });
271
345
 
272
- pullShare.mockImplementationOnce(
273
- () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
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,
274
353
  );
354
+
355
+ // Act
356
+ controller.startShareSync();
357
+ await tick();
358
+
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
+ }),
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
275
382
  const clock = new ManualClock();
276
383
  const { controller } = await connectedController(
277
384
  sessionFixture({ shareId: "p_abc123xy" }),
278
385
  clock,
386
+ liveTransport,
279
387
  );
280
388
 
281
- // Act - start (pull is in flight), leave, then let the pull settle
282
- const stop = controller.startSharePoll();
389
+ controller.startShareSync();
390
+ await tick();
283
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);
284
395
  await tick();
285
- expect(pullShare).toHaveBeenCalledTimes(1);
286
- stop();
287
- release();
396
+ expect(streams).toHaveLength(1);
397
+ clock.advance(1);
288
398
  await tick();
289
- 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);
290
407
  await tick();
408
+ expect(streams).toHaveLength(2);
409
+ clock.advance(SHARE_RECONNECT_MIN_MS);
410
+ await tick();
411
+ expect(streams).toHaveLength(3);
291
412
 
292
- // Assert - the settled pull's finally must not re-arm the timer
293
- expect(pullShare).toHaveBeenCalledTimes(1);
294
- });
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();
295
418
 
296
- test("a restart during an in-flight pull does not double the poll", async () => {
297
- // Arrange - run A's first pull hangs; later pulls resolve immediately
298
- pullShare.mockClear();
299
- remote = sessionFixture({ annotations: [] });
300
- let release: () => void = () => {};
419
+ // Assert
420
+ expect(streams).toHaveLength(4);
421
+ });
301
422
 
302
- pullShare.mockImplementationOnce(
303
- () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
304
- );
423
+ test("stop closes the stream and a late close never reconnects", async () => {
424
+ // Arrange
305
425
  const clock = new ManualClock();
306
426
  const { controller } = await connectedController(
307
427
  sessionFixture({ shareId: "p_abc123xy" }),
308
428
  clock,
429
+ liveTransport,
309
430
  );
431
+ const stop = controller.startShareSync();
310
432
 
311
- // Act - run A starts (pull hangs), then run B restarts while A is in flight
312
- controller.startSharePoll();
313
433
  await tick();
314
- expect(pullShare).toHaveBeenCalledTimes(1);
315
- const stopB = controller.startSharePoll();
316
434
 
435
+ // Act
436
+ stop();
437
+ streams[0]!.handlers.onClose("killed");
438
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
317
439
  await tick();
318
- expect(pullShare).toHaveBeenCalledTimes(2);
319
440
 
320
- // A's stale pull settles - it must not re-arm; then one interval fires only run B
321
- 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();
322
460
  await tick();
323
- clock.advance(SHARE_POLL_MS);
461
+ streams[0]!.handlers.onClose("stale");
462
+ clock.advance(SHARE_RECONNECT_MAX_MS * 2);
324
463
  await tick();
325
464
 
326
- // Assert - exactly one more pull (run B), not two
327
- expect(pullShare).toHaveBeenCalledTimes(3);
328
- 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);
329
469
  });
330
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
+ });