@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.
- package/package.json +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
package/src/thread-chords.ts
CHANGED
|
@@ -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
|
|
3
|
+
export interface NavKey {
|
|
12
4
|
name: string;
|
|
13
|
-
|
|
14
|
-
meta?: boolean;
|
|
15
|
-
super?: boolean;
|
|
5
|
+
shift?: boolean;
|
|
16
6
|
}
|
|
17
7
|
|
|
18
|
-
export interface
|
|
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
|
-
|
|
84
|
-
export function diffChordEntries(hint: string): ChordEntry[] {
|
|
20
|
+
export function sessionCommandEntries(): ChordEntry[] {
|
|
85
21
|
return [
|
|
86
|
-
{ keys:
|
|
87
|
-
{ keys:
|
|
88
|
-
{ keys:
|
|
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
|
-
|
|
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:
|
|
106
|
-
{ keys:
|
|
107
|
-
{ keys:
|
|
108
|
-
{ keys:
|
|
109
|
-
{ keys:
|
|
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
|
-
|
|
116
|
-
export function treeChordEntries(hint: string): ChordEntry[] {
|
|
38
|
+
export function curationCommandEntries(): ChordEntry[] {
|
|
117
39
|
return [
|
|
118
|
-
{ keys:
|
|
119
|
-
{ keys:
|
|
120
|
-
{ keys:
|
|
121
|
-
{ keys:
|
|
122
|
-
{ keys:
|
|
123
|
-
{ keys:
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
context
|
|
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
|
-
|
|
66
|
+
return intent;
|
|
175
67
|
}
|
|
176
68
|
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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.
|
|
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: "
|
|
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
|
+
});
|