@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
@@ -1,186 +1,75 @@
1
- /**
2
- * The session-level chords of the thread view. Letters in the thread view
3
- * type a comment, so everything that acts on the session as a whole - submit,
4
- * share, edit, the rail - lives on ctrl chords (cmd where the terminal
5
- * delivers it). The guided walk is a diff-review mode and has no chord here. Pure: the view reports whether a composer is open, this maps
6
- * a key to an intent or to nothing.
7
- */
8
-
9
1
  import type { Intent } from "./keymap";
10
2
 
11
- export interface ThreadChordKey {
3
+ export interface NavKey {
12
4
  name: string;
13
- ctrl?: boolean;
14
- meta?: boolean;
15
- super?: boolean;
5
+ shift?: boolean;
16
6
  }
17
7
 
18
- export interface ThreadChordContext {
19
- /** A composer owns the keyboard: chords never fire while one is open. */
20
- composing: boolean;
21
- /** Collaborators annotate only; submit, edit, and share are the owner's. */
8
+ export interface ThreadNavContext {
22
9
  isOwner: boolean;
23
- /** A resolved session has nothing left to submit. */
24
10
  resolved: boolean;
25
- /** The rail shows the tree: next / previous move its selection instead of the cards. */
26
11
  treeActive: boolean;
27
- /** A diff review: the block chords act on the code row under the caret (reject, fold, walk). */
28
12
  isDiff?: boolean;
29
13
  }
30
14
 
31
- export const DEFAULT_LEADER = "ctrl+g";
32
-
33
- export function leaderCombosFor(configured: readonly string[] | undefined): readonly string[] {
34
- return configured ?? [DEFAULT_LEADER];
35
- }
36
-
37
- const MOD_GLYPHS = new Map<string, string>([
38
- ["ctrl", "⌃"],
39
- ["control", "⌃"],
40
- ["meta", "⌥"],
41
- ["alt", "⌥"],
42
- ["option", "⌥"],
43
- ["shift", "⇧"],
44
- ["cmd", "⌘"],
45
- ["super", "⌘"],
46
- ]);
47
-
48
- function comboParts(combo: string) {
49
- const parts = combo.split("+");
50
-
51
- return { mods: parts.slice(0, -1).map((part) => part.toLowerCase()), name: parts.at(-1) ?? "" };
52
- }
53
-
54
- export function leaderHint(combos: readonly string[]): string {
55
- const { mods, name } = comboParts(combos[0] ?? DEFAULT_LEADER);
56
-
57
- return `${mods.map((mod) => MOD_GLYPHS.get(mod) ?? mod).join("")}${name} `;
58
- }
59
-
60
- export function matchesLeader(
61
- key: { ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean; name: string },
62
- combos: readonly string[],
63
- ): boolean {
64
- return combos.some((combo) => {
65
- const { mods, name } = comboParts(combo);
66
-
67
- return (
68
- key.name.toLowerCase() === name.toLowerCase() &&
69
- Boolean(key.ctrl) === (mods.includes("ctrl") || mods.includes("control")) &&
70
- Boolean(key.shift) === mods.includes("shift") &&
71
- Boolean(key.super) === (mods.includes("cmd") || mods.includes("super")) &&
72
- Boolean(key.meta) ===
73
- (mods.includes("meta") || mods.includes("alt") || mods.includes("option"))
74
- );
75
- });
76
- }
77
-
78
15
  export interface ChordEntry {
79
16
  keys: string;
80
17
  label: string;
81
18
  }
82
19
 
83
- /** The diff-review chords: the leader (or Option) plus a letter, on the caret's row or file. */
84
- export function diffChordEntries(hint: string): ChordEntry[] {
20
+ export function sessionCommandEntries(): ChordEntry[] {
85
21
  return [
86
- { keys: `${hint}x / ${hint}X`, label: "reject the change / the hunk" },
87
- { keys: `${hint}u`, label: "restore the last rejection" },
88
- { keys: `${hint}c`, label: "collapse the file to its band" },
89
- { keys: `${hint}d`, label: "split / stacked (when wide)" },
90
- { keys: `${hint}k`, label: "start the guided walk" },
22
+ { keys: "⏎", label: "submit the review" },
23
+ { keys: "e", label: "edit in $EDITOR" },
24
+ { keys: "s", label: "share" },
91
25
  ];
92
26
  }
93
27
 
94
- /** The session chords stay on Ctrl, which terminals deliver reliably. */
95
- export const THREAD_CHORD_ENTRIES = [
96
- { keys: "⌃enter", label: "submit the review" },
97
- { keys: "⌃e", label: "edit in $EDITOR" },
98
- { keys: "⌃s", label: "share" },
99
- { keys: "⌃r", label: "cycle the rail" },
100
- ] as const;
101
-
102
- /** The rail and curation chords: the leader plus the plan sheet's old letter. */
103
- export function railChordEntries(hint: string): ChordEntry[] {
28
+ export function diffCommandEntries(): ChordEntry[] {
104
29
  return [
105
- { keys: `${hint}n / ${hint}p`, label: "next / previous card" },
106
- { keys: `${hint}e`, label: "edit the card" },
107
- { keys: `${hint}⌫`, label: "delete the card" },
108
- { keys: `${hint}r`, label: "rename the author" },
109
- { keys: `${hint}x`, label: "cut the block" },
110
- { keys: `${hint}u`, label: "restore the last cut" },
111
- { keys: "⌥w / ⌥s", label: "widen / narrow the rail" },
30
+ { keys: "x", label: "reject the change under the caret" },
31
+ { keys: "u", label: "restore the last rejection" },
32
+ { keys: "c", label: "collapse the file to its band" },
33
+ { keys: "d", label: "split / stacked (when wide)" },
34
+ { keys: "k", label: "start the guided walk" },
112
35
  ];
113
36
  }
114
37
 
115
- /** The tree chords: the leader plus a letter, all on the rail's Tree tab. */
116
- export function treeChordEntries(hint: string): ChordEntry[] {
38
+ export function curationCommandEntries(): ChordEntry[] {
117
39
  return [
118
- { keys: `${hint}t`, label: "show / hide the tree" },
119
- { keys: `${hint}n / ${hint}p`, label: "next / previous entry" },
120
- { keys: `${hint}g`, label: "go to the entry" },
121
- { keys: `${hint}b`, label: "branch off the tip" },
122
- { keys: `${hint}l`, label: "label a checkpoint" },
123
- { keys: `${hint}f`, label: "fork the path" },
124
- { keys: `${hint}h`, label: "fork and hand off" },
40
+ { keys: "n / p", label: "next / previous card" },
41
+ { keys: "z", label: "fold / unfold the card" },
42
+ { keys: "⌫", label: "delete the card" },
43
+ { keys: "r", label: "rename the author" },
44
+ { keys: "x", label: "cut the block" },
45
+ { keys: "u", label: "restore the last cut" },
125
46
  ];
126
47
  }
127
48
 
128
- export function resolveThreadChord(
129
- key: ThreadChordKey,
130
- context: ThreadChordContext,
131
- ): Intent | null {
132
- if (context.composing) return null;
133
- const modified = Boolean(key.ctrl || key.meta || key.super);
134
-
135
- if (!modified) return null;
136
- if (key.name === "return" || key.name === "enter") {
137
- if (!context.isOwner) return READ_ONLY;
138
-
139
- return context.resolved ? null : { type: "openSubmit" };
140
- }
141
- if (key.ctrl) return resolveSessionChord(key.name, context);
142
- if (key.meta)
143
- return (
144
- (context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
145
- );
146
-
147
- return null;
49
+ export function treeCommandEntries(): ChordEntry[] {
50
+ return [
51
+ { keys: "g", label: "go to the entry" },
52
+ { keys: "b", label: "branch off the tip" },
53
+ { keys: "l", label: "label a checkpoint" },
54
+ { keys: "f", label: "fork the path" },
55
+ { keys: "h", label: "fork and hand off" },
56
+ ];
148
57
  }
149
58
 
150
- export function resolveLeaderCommand(
151
- key: ThreadChordKey,
152
- context: ThreadChordContext,
153
- ): Intent | null {
154
- if (context.composing) return null;
155
- if (key.name === "return" || key.name === "enter") {
156
- if (!context.isOwner) return READ_ONLY;
157
-
158
- return context.resolved ? null : { type: "openSubmit" };
159
- }
160
-
161
- return (
162
- (context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
163
- );
164
- }
59
+ const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
60
+ const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
165
61
 
166
- export function dispatchLeaderCommand(
167
- key: { name: string; shift?: boolean },
168
- context: ThreadChordContext,
169
- dispatch: (intent: Intent) => void,
170
- ): void {
171
- const name = key.shift ? key.name.toUpperCase() : key.name;
172
- const chord = resolveLeaderCommand({ name }, context);
62
+ function mutating(intent: Intent, context: ThreadNavContext): Intent {
63
+ if (!context.isOwner) return READ_ONLY;
64
+ if (context.resolved) return RESOLVED;
173
65
 
174
- if (chord) dispatch(chord);
66
+ return intent;
175
67
  }
176
68
 
177
- /** The diff's row and file chords; null lets the rail chords answer the letter. */
178
- function resolveDiffChord(name: string, context: ThreadChordContext): Intent | null {
69
+ function resolveDiffKey(name: string, context: ThreadNavContext): Intent | null {
179
70
  switch (name) {
180
71
  case "x":
181
72
  return mutating({ type: "rejectChange" }, context);
182
- case "X":
183
- return mutating({ type: "rejectHunk" }, context);
184
73
  case "c":
185
74
  return { type: "foldFile" };
186
75
  case "d":
@@ -192,19 +81,7 @@ function resolveDiffChord(name: string, context: ThreadChordContext): Intent | n
192
81
  }
193
82
  }
194
83
 
195
- /** The answers a blocked primitive gets - the same words the keymap uses. */
196
- const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
197
- const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
198
-
199
- /** Editing, deleting, cutting, and restoring change the review: gated by role and by a verdict. */
200
- function mutating(intent: Intent, context: ThreadChordContext): Intent {
201
- if (!context.isOwner) return READ_ONLY;
202
- if (context.resolved) return RESOLVED;
203
-
204
- return intent;
205
- }
206
-
207
- function resolveSessionChord(name: string, context: ThreadChordContext): Intent | null {
84
+ function resolveSessionKey(name: string, context: ThreadNavContext): Intent | null {
208
85
  switch (name) {
209
86
  case "e":
210
87
  return mutating({ type: "edit" }, context);
@@ -215,33 +92,24 @@ function resolveSessionChord(name: string, context: ThreadChordContext): Intent
215
92
  }
216
93
  }
217
94
 
218
- // option arrives as meta in this terminal stack; the letters mirror the
219
- // plan sheet's keymap so the rail keeps its muscle memory
220
- function resolveRailChord(name: string, context: ThreadChordContext): Intent | null {
95
+ function resolveCurationKey(name: string, context: ThreadNavContext): Intent | null {
221
96
  switch (name) {
222
97
  case "n":
223
98
  return context.treeActive ? { type: "treeMove", direction: 1 } : { type: "nextAnnotation" };
224
99
  case "p":
225
100
  return context.treeActive ? { type: "treeMove", direction: -1 } : { type: "prevAnnotation" };
226
- case "t":
227
- return { type: "toggleTree" };
228
- // the tree is the owner's: a move, a branch, or a label changes what everyone sees
229
101
  case "g":
230
102
  return mutating({ type: "treeGo" }, context);
231
103
  case "b":
232
104
  return mutating({ type: "treeBranch" }, context);
233
105
  case "l":
234
106
  return mutating({ type: "treeLabel" }, context);
235
- // a fork can be taken from a resolved review: only the role gates it
236
107
  case "f":
237
108
  return context.isOwner ? { type: "treeFork" } : READ_ONLY;
238
109
  case "h":
239
110
  return context.isOwner ? { type: "treeForkShare" } : READ_ONLY;
240
- case "e":
241
- return mutating({ type: "editCard" }, context);
242
111
  case "backspace":
243
112
  return mutating({ type: "removeAnnotation" }, context);
244
- // renaming an author is a local display choice, open to every role
245
113
  case "r":
246
114
  return { type: "openRename" };
247
115
  case "x":
@@ -252,3 +120,44 @@ function resolveRailChord(name: string, context: ThreadChordContext): Intent | n
252
120
  return null;
253
121
  }
254
122
  }
123
+
124
+ export interface SessionChordKey {
125
+ name: string;
126
+ ctrl?: boolean;
127
+ meta?: boolean;
128
+ super?: boolean;
129
+ }
130
+
131
+ export function resolveSessionChord(
132
+ key: SessionChordKey,
133
+ context: Pick<ThreadNavContext, "isOwner" | "resolved">,
134
+ ): Intent | null {
135
+ if (key.name === "return" || key.name === "enter") {
136
+ if (!(key.ctrl || key.meta || key.super)) return null;
137
+ if (!context.isOwner) return READ_ONLY;
138
+
139
+ return context.resolved ? null : { type: "openSubmit" };
140
+ }
141
+ if (!key.ctrl) return null;
142
+ if (key.name === "e")
143
+ return context.resolved ? RESOLVED : context.isOwner ? { type: "edit" } : READ_ONLY;
144
+ if (key.name === "s") return context.isOwner ? { type: "share" } : READ_ONLY;
145
+
146
+ return null;
147
+ }
148
+
149
+ export function resolveNavKey(key: NavKey, context: ThreadNavContext): Intent | null {
150
+ const name = key.shift ? key.name.toUpperCase() : key.name;
151
+
152
+ if (name === "return" || name === "enter") {
153
+ if (!context.isOwner) return READ_ONLY;
154
+
155
+ return context.resolved ? null : { type: "openSubmit" };
156
+ }
157
+
158
+ return (
159
+ (context.isDiff ? resolveDiffKey(name, context) : null) ??
160
+ resolveSessionKey(name, context) ??
161
+ resolveCurationKey(name, context)
162
+ );
163
+ }
@@ -118,6 +118,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
118
118
  sessionSetViewed: unimplemented("sessionSetViewed"),
119
119
  sessionSetTitle: unimplemented("sessionSetTitle"),
120
120
  sessionSetShareId: unimplemented("sessionSetShareId"),
121
+ sessionSetShares: unimplemented("sessionSetShares"),
121
122
  sessionMergeShared: unimplemented("sessionMergeShared"),
122
123
  sessionDelete: unimplemented("sessionDelete"),
123
124
  sessionSetSelfName: unimplemented("sessionSetSelfName"),
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { sameDerivationInputs } from "./thread-controller";
4
+
5
+ function threadFixture(overrides: Partial<Thread> = {}): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
11
+ revisions: [
12
+ { revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
13
+ ],
14
+ annotations: [],
15
+ verdict: null,
16
+ status: "pending",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ const annotation: Annotation = {
23
+ id: "ann_1",
24
+ kind: "comment",
25
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
26
+ body: "nit",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ };
29
+
30
+ describe("sameDerivationInputs", () => {
31
+ test("an annotation-only update reuses the projection (a fresh record, identical inputs)", () => {
32
+ // Arrange - the daemon re-serializes the whole thread on any change, so the record is all-new
33
+ const before = threadFixture();
34
+ const after = threadFixture({ annotations: [annotation] });
35
+
36
+ // Assert - the artifact and working copy are unchanged, so the projection can be reused
37
+ expect(sameDerivationInputs(before, after)).toBe(true);
38
+ });
39
+
40
+ test("a content edit forces a re-derivation", () => {
41
+ const before = threadFixture();
42
+ const after = threadFixture({
43
+ artifact: { type: "plan", content: "# Plan\n\nShip it soon.\n", meta: {} },
44
+ });
45
+
46
+ expect(sameDerivationInputs(before, after)).toBe(false);
47
+ });
48
+
49
+ test("a working-copy change forces a re-derivation", () => {
50
+ const before = threadFixture();
51
+ const after = threadFixture({ workingCopy: "# Plan\n\nShip it.\n\nedited\n" });
52
+
53
+ expect(sameDerivationInputs(before, after)).toBe(false);
54
+ });
55
+
56
+ test("a different thread is never a reuse", () => {
57
+ const before = threadFixture();
58
+ const after = threadFixture({ id: "ses_2" });
59
+
60
+ expect(sameDerivationInputs(before, after)).toBe(false);
61
+ });
62
+ });
@@ -38,7 +38,9 @@ const shareTransport: ShareTransport = {
38
38
  pull: mock(async () => planSession()),
39
39
  push: mock(async () => {}),
40
40
  watch: () => () => {},
41
+ revoke: async () => {},
41
42
  parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
43
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
42
44
  collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
43
45
  mergeFromShare,
44
46
  };
@@ -82,6 +84,7 @@ function fakeClient(session: Thread): SessionClient {
82
84
  sessionSetViewed: unimplemented("sessionSetViewed"),
83
85
  sessionSetTitle: unimplemented("sessionSetTitle"),
84
86
  sessionSetShareId: unimplemented("sessionSetShareId"),
87
+ sessionSetShares: unimplemented("sessionSetShares"),
85
88
  sessionMergeShared: unimplemented("sessionMergeShared"),
86
89
  sessionDelete: unimplemented("sessionDelete"),
87
90
  sessionSetSelfName: unimplemented("sessionSetSelfName"),
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { withAnnotationUpserted } from "./thread-controller";
4
+
5
+ function threadFixture(annotations: Annotation[] = []): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
11
+ revisions: [
12
+ { revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
13
+ ],
14
+ annotations,
15
+ verdict: null,
16
+ status: "pending",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ };
19
+ }
20
+
21
+ const existing: Annotation = {
22
+ id: "ann_1",
23
+ kind: "comment",
24
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
25
+ body: "first",
26
+ createdAt: "2026-01-01T00:00:00.000Z",
27
+ };
28
+
29
+ describe("withAnnotationUpserted", () => {
30
+ test("a new comment is appended so it paints immediately", () => {
31
+ // Arrange
32
+ const session = threadFixture([existing]);
33
+ const wire = { id: "ann_2", kind: "comment", anchor: existing.anchor, body: "second" };
34
+
35
+ // Act
36
+ const next = withAnnotationUpserted(session, wire);
37
+
38
+ // Assert
39
+ expect(next.annotations.map((entry) => entry.id)).toEqual(["ann_1", "ann_2"]);
40
+ expect(next.annotations[1]!.body).toBe("second");
41
+ expect(next.annotations[1]!.createdAt).not.toBe("");
42
+ });
43
+
44
+ test("an edit replaces the note in place and keeps its timestamp", () => {
45
+ // Arrange
46
+ const session = threadFixture([existing]);
47
+ const wire = { id: "ann_1", kind: "comment", anchor: existing.anchor, body: "edited" };
48
+
49
+ // Act
50
+ const next = withAnnotationUpserted(session, wire);
51
+
52
+ // Assert - same position, same createdAt, new body (no reorder or timestamp flash)
53
+ expect(next.annotations.map((entry) => entry.id)).toEqual(["ann_1"]);
54
+ expect(next.annotations[0]!.body).toBe("edited");
55
+ expect(next.annotations[0]!.createdAt).toBe(existing.createdAt);
56
+ });
57
+
58
+ test("the input session is not mutated", () => {
59
+ // Arrange
60
+ const session = threadFixture([existing]);
61
+ const wire = { id: "ann_2", kind: "comment", anchor: existing.anchor, body: "second" };
62
+
63
+ // Act
64
+ withAnnotationUpserted(session, wire);
65
+
66
+ // Assert
67
+ expect(session.annotations).toHaveLength(1);
68
+ });
69
+ });
@@ -17,16 +17,20 @@ const pushShare = mock(
17
17
  async (
18
18
  _shareId: string,
19
19
  _annotations: Array<Omit<Annotation, "createdAt">>,
20
- _access?: ShareAccess,
20
+ _access?: ShareAccess | "public",
21
21
  ) => {},
22
22
  );
23
23
 
24
+ const revokeShare = mock(async (_shareId: string) => {});
25
+
24
26
  const shareTransport: ShareTransport = {
25
27
  publish: publishShare,
26
28
  pull: pullShare,
27
29
  push: pushShare,
28
30
  watch: () => () => {},
31
+ revoke: revokeShare,
29
32
  parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
33
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
30
34
  collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
31
35
  mergeFromShare,
32
36
  };
@@ -88,6 +92,12 @@ function fakeClient(session: Thread): FakeSessionClient {
88
92
  sessionSetShareId: mock(
89
93
  async (_id: string, shareId: string) => ((session.shareId = shareId), session),
90
94
  ),
95
+ sessionSetShares: mock(
96
+ async (_id: string, shares: import("@cueloop/schema").ShareLink[]) => (
97
+ (session.shares = shares),
98
+ session
99
+ ),
100
+ ),
91
101
  sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
92
102
  const known = new Set(session.annotations.map((existing) => existing.id));
93
103
 
@@ -125,7 +135,7 @@ async function connectedController(
125
135
  }
126
136
 
127
137
  describe("share", () => {
128
- test("stamps the returned share id back on the session", async () => {
138
+ test("publishes a public link and records it on the session", async () => {
129
139
  // Arrange
130
140
  const { controller, client } = await connectedController(sessionFixture());
131
141
 
@@ -133,8 +143,10 @@ describe("share", () => {
133
143
  controller.share();
134
144
  await tick();
135
145
 
136
- // Assert
137
- expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
146
+ // Assert - a public link (no auth) is persisted with the returned id
147
+ expect(client.sessionSetShares).toHaveBeenCalledWith("ses_1", [
148
+ { id: "p_abc123xy", name: undefined, requireAuth: false, allowlist: [], shareBranch: "main" },
149
+ ]);
138
150
  });
139
151
 
140
152
  test("surfaces the ssh line as a toast, not an inline status", async () => {
@@ -148,7 +160,7 @@ describe("share", () => {
148
160
  // Assert
149
161
  expect(controller.getSnapshot().toast).toEqual({
150
162
  body: "ssh p_abc123xy@cueloop.dev",
151
- title: "share link copied",
163
+ title: "link copied",
152
164
  });
153
165
  expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
154
166
  });
@@ -504,3 +516,46 @@ describe("startShareSync", () => {
504
516
  expect(streams[1]!.stopped).toBe(false);
505
517
  });
506
518
  });
519
+
520
+ describe("revoke", () => {
521
+ test("unshare revokes the gateway blob for the shared thread", async () => {
522
+ revokeShare.mockClear();
523
+ const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
524
+
525
+ controller.unshare();
526
+ await tick();
527
+
528
+ expect(revokeShare).toHaveBeenCalledWith("p_abc123xy");
529
+ });
530
+
531
+ test("unshare on an unshared thread is a no-op", async () => {
532
+ revokeShare.mockClear();
533
+ const { controller } = await connectedController(sessionFixture());
534
+
535
+ controller.unshare();
536
+ await tick();
537
+
538
+ expect(revokeShare).not.toHaveBeenCalled();
539
+ });
540
+
541
+ test("deleting a shared thread revokes its share first", async () => {
542
+ revokeShare.mockClear();
543
+ const session = sessionFixture({ shareId: "p_abc123xy" });
544
+ const client = fakeClient(session);
545
+
546
+ client.sessionDelete = mock(async () => {});
547
+ const controller = createReviewController({
548
+ sessionId: session.id,
549
+ openClient: async () => client,
550
+ shareTransport,
551
+ });
552
+
553
+ controller.connect();
554
+ await tick();
555
+ controller.deleteSession(session.id);
556
+ await tick();
557
+
558
+ expect(revokeShare).toHaveBeenCalledWith("p_abc123xy");
559
+ expect(client.sessionDelete).toHaveBeenCalledWith(session.id);
560
+ });
561
+ });