@cueloop/client 0.1.0-alpha.79 → 0.1.0-alpha.80
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 +5 -5
- package/src/App.diff.test.tsx +2 -2
- package/src/App.export.test.tsx +3 -3
- package/src/App.plan-review.test.tsx +1 -1
- package/src/App.readonly.test.tsx +2 -2
- package/src/App.submit-confirm.test.tsx +7 -7
- package/src/App.test.tsx +11 -11
- package/src/App.tsx +16 -11
- package/src/App.walk.test.tsx +5 -5
- package/src/app-key-state.ts +1 -1
- package/src/app-screens.tsx +5 -5
- package/src/app-view-model.ts +5 -5
- package/src/components/AnnotatableFileView.stories.tsx +1 -1
- package/src/components/AnnotatableFileView.test.tsx +1 -1
- package/src/components/BareWorkbenchFileView.tsx +1 -1
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +6 -1
- package/src/components/CompletionOverlay.stories.tsx +2 -2
- package/src/components/CompletionOverlay.tsx +4 -4
- package/src/components/ConfirmCard.stories.tsx +4 -4
- package/src/components/ConfirmCard.tsx +31 -32
- package/src/components/DiffContentView.tsx +1 -1
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/ThreadFooter.test.tsx +12 -1
- package/src/components/ThreadFooter.tsx +7 -2
- package/src/components/ThreadTree.stories.tsx +1 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.tsx +1 -1
- package/src/components/WelcomePlayground.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +8 -8
- package/src/components/story-fixtures.ts +2 -2
- package/src/components/{session-tree.test.ts → thread-tree.test.ts} +2 -2
- package/src/config.test.ts +10 -10
- package/src/config.ts +6 -9
- package/src/integrations.test.ts +8 -8
- package/src/integrations.ts +4 -4
- package/src/intent-dispatch.test.ts +10 -10
- package/src/intent-dispatch.ts +15 -14
- package/src/key-bindings.test.ts +4 -4
- package/src/key-bindings.ts +10 -10
- package/src/keymap.test.ts +5 -5
- package/src/keymap.ts +7 -7
- package/src/thread-controller.curate.test.ts +15 -5
- package/src/thread-controller.derivation.test.ts +1 -1
- package/src/thread-controller.live-diff.test.ts +10 -10
- package/src/thread-controller.optimistic.test.ts +1 -1
- package/src/thread-controller.share.test.ts +8 -8
- package/src/thread-controller.switch-cache.test.ts +4 -4
- package/src/thread-controller.tree.test.ts +4 -4
- package/src/thread-controller.ts +12 -12
- package/src/tree-view.test.ts +1 -1
- package/src/tree-view.ts +3 -3
- package/src/use-annotation-surface.tsx +1 -1
- package/src/view-diff.test.ts +1 -1
- package/src/workbench-snapshot.test.ts +1 -1
- /package/src/components/{session-tree.ts → thread-tree.ts} +0 -0
package/src/intent-dispatch.ts
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
11
|
-
import { isAddressed, type Thread, type
|
|
11
|
+
import { isAddressed, type Thread, type MessageOutcome } from "@cueloop/schema";
|
|
12
12
|
import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
|
|
13
13
|
import type { DiffRow } from "./view-diff";
|
|
14
14
|
import type { ReviewController } from "./thread-controller";
|
|
15
15
|
import type { Intent } from "./keymap";
|
|
16
16
|
import type { TreeRow } from "./tree-view";
|
|
17
17
|
import { quickActionBody, type QuickAction } from "./config";
|
|
18
|
-
import {
|
|
18
|
+
import { MESSAGE_OUTCOMES } from "./components/ConfirmCard";
|
|
19
19
|
|
|
20
20
|
/** Which pane of the session tree / review the keyboard grammar is aimed at. */
|
|
21
21
|
export type RailTab = "review" | "tree";
|
|
@@ -34,7 +34,7 @@ export type Mode =
|
|
|
34
34
|
text: string;
|
|
35
35
|
}
|
|
36
36
|
| { type: "railEdit"; id: string; text: string }
|
|
37
|
-
| { type: "submit";
|
|
37
|
+
| { type: "submit"; message: MessageOutcome; summary: string }
|
|
38
38
|
| { type: "confirmDelete"; sessionId: string; title: string }
|
|
39
39
|
| { type: "rename"; authorId: string; text: string }
|
|
40
40
|
| { type: "renameThread"; sessionId: string; text: string }
|
|
@@ -68,7 +68,7 @@ export interface IntentDispatchDeps {
|
|
|
68
68
|
inboxCursor: number;
|
|
69
69
|
mode: Mode;
|
|
70
70
|
session: Thread | null;
|
|
71
|
-
|
|
71
|
+
defaultMessage: MessageOutcome;
|
|
72
72
|
focusedAnnotationId: string | undefined;
|
|
73
73
|
/** The curation item selected for undo, if any. */
|
|
74
74
|
selectedCurationId: string | undefined;
|
|
@@ -368,7 +368,7 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
|
|
|
368
368
|
|
|
369
369
|
if (!session) return;
|
|
370
370
|
deps.liveInput.current = "";
|
|
371
|
-
deps.setMode({ type: "submit",
|
|
371
|
+
deps.setMode({ type: "submit", message: deps.defaultMessage, summary: "" });
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
// share opens the share dialog; it owns its own keys, links list, and publish wizard
|
|
@@ -524,24 +524,25 @@ function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDis
|
|
|
524
524
|
deps.setMode({ type: "normal" });
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
-
function
|
|
528
|
-
_intent: IntentOfType<"
|
|
527
|
+
function handleSubmitMessage(
|
|
528
|
+
_intent: IntentOfType<"submitMessage">,
|
|
529
529
|
deps: IntentDispatchDeps,
|
|
530
530
|
): void {
|
|
531
531
|
const { mode } = deps;
|
|
532
532
|
|
|
533
|
-
if (mode.type === "submit") deps.controller.submit(mode.
|
|
533
|
+
if (mode.type === "submit") deps.controller.submit(mode.message, deps.liveInput.current);
|
|
534
534
|
deps.setMode({ type: "normal" });
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
-
function
|
|
537
|
+
function handleCycleMessage(intent: IntentOfType<"cycleMessage">, deps: IntentDispatchDeps): void {
|
|
538
538
|
const { mode } = deps;
|
|
539
539
|
|
|
540
540
|
if (mode.type !== "submit") return;
|
|
541
|
-
const
|
|
542
|
-
(
|
|
541
|
+
const messageIndex =
|
|
542
|
+
(MESSAGE_OUTCOMES.indexOf(mode.message) + intent.direction + MESSAGE_OUTCOMES.length) %
|
|
543
|
+
MESSAGE_OUTCOMES.length;
|
|
543
544
|
|
|
544
|
-
deps.setMode({ ...mode,
|
|
545
|
+
deps.setMode({ ...mode, message: MESSAGE_OUTCOMES[messageIndex]! });
|
|
545
546
|
}
|
|
546
547
|
|
|
547
548
|
function handleFinishReview(_intent: IntentOfType<"finishReview">, deps: IntentDispatchDeps): void {
|
|
@@ -664,8 +665,8 @@ const intentHandlers: IntentHandlers = {
|
|
|
664
665
|
deselect: handleDeselect,
|
|
665
666
|
closeOverlay: handleCloseOverlay,
|
|
666
667
|
saveCompose: handleSaveCompose,
|
|
667
|
-
|
|
668
|
-
|
|
668
|
+
submitMessage: handleSubmitMessage,
|
|
669
|
+
cycleMessage: handleCycleMessage,
|
|
669
670
|
finishReview: handleFinishReview,
|
|
670
671
|
optInAutoClose: handleOptInAutoClose,
|
|
671
672
|
dismissCompletion: handleDismissCompletion,
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -63,14 +63,14 @@ describe("mode layers own their keys", () => {
|
|
|
63
63
|
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
test("the submit overlay adds the
|
|
66
|
+
test("the submit overlay adds the message arrows", () => {
|
|
67
67
|
// Arrange
|
|
68
68
|
const resolver = bindings({ overlay: "submit" });
|
|
69
69
|
|
|
70
70
|
// Assert
|
|
71
|
-
expect(resolver.resolveAction({ name: "left", shift: false })).toBe("
|
|
72
|
-
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("
|
|
73
|
-
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("
|
|
71
|
+
expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_outcome_left");
|
|
72
|
+
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_outcome_right");
|
|
73
|
+
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("send_message");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
|
package/src/key-bindings.ts
CHANGED
|
@@ -58,7 +58,7 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
58
58
|
{ commands: ["submit"], label: "submit" },
|
|
59
59
|
{ commands: ["quit"], label: "quit" },
|
|
60
60
|
],
|
|
61
|
-
// A share viewer: annotate and navigate, no plan edit /
|
|
61
|
+
// A share viewer: annotate and navigate, no plan edit / message submit.
|
|
62
62
|
collaborator: [
|
|
63
63
|
{ commands: ["down", "up"], label: "move" },
|
|
64
64
|
{ commands: ["span"], label: "span" },
|
|
@@ -90,11 +90,11 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
90
90
|
],
|
|
91
91
|
submit: [
|
|
92
92
|
{
|
|
93
|
-
commands: ["
|
|
93
|
+
commands: ["cycle_outcome_left", "cycle_outcome_right"],
|
|
94
94
|
label: "decision",
|
|
95
95
|
labelFirst: true,
|
|
96
96
|
},
|
|
97
|
-
{ commands: ["
|
|
97
|
+
{ commands: ["send_message"], label: "submit" },
|
|
98
98
|
{ commands: ["cancel_submit"], label: "cancel" },
|
|
99
99
|
],
|
|
100
100
|
walk: [
|
|
@@ -329,16 +329,16 @@ export class KeyBindings {
|
|
|
329
329
|
priority: 20,
|
|
330
330
|
when: () => this.context.overlay === "submit",
|
|
331
331
|
bindings: [
|
|
332
|
-
{ key: "return", cmd: "
|
|
333
|
-
{ key: "enter", cmd: "
|
|
334
|
-
{ key: "left", cmd: "
|
|
335
|
-
{ key: "right", cmd: "
|
|
332
|
+
{ key: "return", cmd: "send_message" },
|
|
333
|
+
{ key: "enter", cmd: "send_message" },
|
|
334
|
+
{ key: "left", cmd: "cycle_outcome_left" },
|
|
335
|
+
{ key: "right", cmd: "cycle_outcome_right" },
|
|
336
336
|
{ key: "escape", cmd: "cancel_submit" },
|
|
337
337
|
],
|
|
338
338
|
commands: [
|
|
339
|
-
record("
|
|
340
|
-
record("
|
|
341
|
-
record("
|
|
339
|
+
record("send_message"),
|
|
340
|
+
record("cycle_outcome_left"),
|
|
341
|
+
record("cycle_outcome_right"),
|
|
342
342
|
record("cancel_submit"),
|
|
343
343
|
],
|
|
344
344
|
});
|
package/src/keymap.test.ts
CHANGED
|
@@ -61,8 +61,8 @@ describe("submit overlay", () => {
|
|
|
61
61
|
["escape", [{ type: "closeOverlay" }]],
|
|
62
62
|
["return", []],
|
|
63
63
|
["enter", []],
|
|
64
|
-
["left", [{ type: "
|
|
65
|
-
["right", [{ type: "
|
|
64
|
+
["left", [{ type: "cycleMessage", direction: -1 }]],
|
|
65
|
+
["right", [{ type: "cycleMessage", direction: 1 }]],
|
|
66
66
|
["j", []],
|
|
67
67
|
["q", []],
|
|
68
68
|
];
|
|
@@ -134,7 +134,7 @@ describe("inbox mode", () => {
|
|
|
134
134
|
describe("collaborator (share) capabilities", () => {
|
|
135
135
|
test("can annotate: comment opens the composer", () => {
|
|
136
136
|
// Arrange
|
|
137
|
-
const collab = state({ canEditPlan: false,
|
|
137
|
+
const collab = state({ canEditPlan: false, canSubmitMessage: false });
|
|
138
138
|
|
|
139
139
|
// Act / Assert
|
|
140
140
|
expect(reduceKey(collab, key("c"), "comment")).toEqual([
|
|
@@ -159,9 +159,9 @@ describe("collaborator (share) capabilities", () => {
|
|
|
159
159
|
expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
test("cannot submit a
|
|
162
|
+
test("cannot submit a message: there is no agent on a share", () => {
|
|
163
163
|
// Arrange
|
|
164
|
-
const collab = state({
|
|
164
|
+
const collab = state({ canSubmitMessage: false });
|
|
165
165
|
|
|
166
166
|
// Act / Assert
|
|
167
167
|
expect(reduceKey(collab, key("x"), "submit")).toEqual([
|
package/src/keymap.ts
CHANGED
|
@@ -46,8 +46,8 @@ export type Intent =
|
|
|
46
46
|
| { type: "deselect" }
|
|
47
47
|
| { type: "closeOverlay" }
|
|
48
48
|
| { type: "saveCompose" }
|
|
49
|
-
| { type: "
|
|
50
|
-
| { type: "
|
|
49
|
+
| { type: "submitMessage" }
|
|
50
|
+
| { type: "cycleMessage"; direction: -1 | 1 }
|
|
51
51
|
| { type: "finishReview" }
|
|
52
52
|
| { type: "optInAutoClose" }
|
|
53
53
|
| { type: "dismissCompletion" }
|
|
@@ -73,10 +73,10 @@ export interface KeyState {
|
|
|
73
73
|
/**
|
|
74
74
|
* Owner-only primitives a share collaborator lacks (undefined = owner, allowed).
|
|
75
75
|
* A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
|
|
76
|
-
* the gateway) or submit an agent
|
|
76
|
+
* the gateway) or submit an agent message (there is no agent on a share).
|
|
77
77
|
*/
|
|
78
78
|
canEditPlan?: boolean;
|
|
79
|
-
|
|
79
|
+
canSubmitMessage?: boolean;
|
|
80
80
|
/** Owner-only: publish the plan as a share. A collaborator never re-shares. */
|
|
81
81
|
canShare?: boolean;
|
|
82
82
|
/** Layer that owns keys before the grammar runs. */
|
|
@@ -193,7 +193,7 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
193
193
|
|
|
194
194
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
195
195
|
if (name === "left" || name === "right") {
|
|
196
|
-
return [{ type: "
|
|
196
|
+
return [{ type: "cycleMessage", direction: name === "left" ? -1 : 1 }];
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
return [];
|
|
@@ -398,8 +398,8 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
398
398
|
return [{ type: "openRename" }];
|
|
399
399
|
}
|
|
400
400
|
if (action === "submit") {
|
|
401
|
-
// a collaborator's notes union back as they go; there is no
|
|
402
|
-
if (state.
|
|
401
|
+
// a collaborator's notes union back as they go; there is no message to submit
|
|
402
|
+
if (state.canSubmitMessage === false)
|
|
403
403
|
return status("shared view - your notes save as you go; q to leave");
|
|
404
404
|
|
|
405
405
|
return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type Thread,
|
|
10
10
|
} from "@cueloop/schema";
|
|
11
11
|
import { curateDiff } from "@cueloop/daemon/curate";
|
|
12
|
-
import type {
|
|
12
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
13
13
|
import { createReviewController } from "./thread-controller";
|
|
14
14
|
|
|
15
15
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
@@ -40,7 +40,7 @@ function diffSession(files?: DiffFileContents[]): Thread {
|
|
|
40
40
|
artifact: { type: "diff", content: PATCH, meta: {}, files },
|
|
41
41
|
revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
42
42
|
annotations: [],
|
|
43
|
-
|
|
43
|
+
message: null,
|
|
44
44
|
status: "pending",
|
|
45
45
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
46
46
|
};
|
|
@@ -54,7 +54,7 @@ const unimplemented = (member: string) => () =>
|
|
|
54
54
|
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
55
55
|
|
|
56
56
|
/** A fake client that records the working copy the controller writes. */
|
|
57
|
-
function fakeClient(initial: Thread, sink: WorkingCopySink):
|
|
57
|
+
function fakeClient(initial: Thread, sink: WorkingCopySink): ThreadClient {
|
|
58
58
|
let session = initial;
|
|
59
59
|
|
|
60
60
|
return {
|
|
@@ -122,7 +122,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
|
|
|
122
122
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
123
123
|
sessionDelete: unimplemented("sessionDelete"),
|
|
124
124
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
125
|
-
|
|
125
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
126
126
|
close: () => {},
|
|
127
127
|
};
|
|
128
128
|
}
|
|
@@ -299,6 +299,16 @@ describe("diff hunk curation", () => {
|
|
|
299
299
|
expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
|
|
300
300
|
expect(sink.workingCopy).toBeUndefined();
|
|
301
301
|
});
|
|
302
|
+
|
|
303
|
+
test("curation is disabled when a revision cleared its file snapshots", async () => {
|
|
304
|
+
const { controller, client } = await connected(diffSession([]));
|
|
305
|
+
|
|
306
|
+
controller.toggleRejectChange(4);
|
|
307
|
+
await tick();
|
|
308
|
+
|
|
309
|
+
expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
|
|
310
|
+
expect(client.sessionCurate).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
302
312
|
});
|
|
303
313
|
|
|
304
314
|
const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
|
|
@@ -313,7 +323,7 @@ function planSession(workingCopy?: string): Thread {
|
|
|
313
323
|
artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
|
|
314
324
|
revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
315
325
|
annotations: [],
|
|
316
|
-
|
|
326
|
+
message: null,
|
|
317
327
|
status: "pending",
|
|
318
328
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
319
329
|
workingCopy,
|
|
@@ -12,7 +12,7 @@ function threadFixture(overrides: Partial<Thread> = {}): Thread {
|
|
|
12
12
|
{ revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
|
|
13
13
|
],
|
|
14
14
|
annotations: [],
|
|
15
|
-
|
|
15
|
+
message: null,
|
|
16
16
|
status: "pending",
|
|
17
17
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
18
18
|
...overrides,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { describe, expect, mock, test } from "bun:test";
|
|
9
9
|
import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
|
|
10
|
-
import type {
|
|
10
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
11
11
|
import { createReviewController, type ShareTransport } from "./thread-controller";
|
|
12
12
|
import { mergeFromShare } from "./share";
|
|
13
13
|
|
|
@@ -53,7 +53,7 @@ function planSession(id = "ses_plan", repoRoot = "/repo"): Thread {
|
|
|
53
53
|
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
54
54
|
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
|
|
55
55
|
annotations: [],
|
|
56
|
-
|
|
56
|
+
message: null,
|
|
57
57
|
status: "pending",
|
|
58
58
|
createdAt: AT,
|
|
59
59
|
};
|
|
@@ -62,7 +62,7 @@ function planSession(id = "ses_plan", repoRoot = "/repo"): Thread {
|
|
|
62
62
|
const unimplemented = (member: string) => () =>
|
|
63
63
|
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
64
64
|
|
|
65
|
-
function fakeClient(session: Thread):
|
|
65
|
+
function fakeClient(session: Thread): ThreadClient {
|
|
66
66
|
return {
|
|
67
67
|
onEvent: () => () => {},
|
|
68
68
|
subscribe: async () => {},
|
|
@@ -88,10 +88,10 @@ function fakeClient(session: Thread): SessionClient {
|
|
|
88
88
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
89
89
|
sessionDelete: unimplemented("sessionDelete"),
|
|
90
90
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
91
|
-
|
|
91
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
92
92
|
repoDiff: async () => ({ patch: PATCH, files: FILES }),
|
|
93
93
|
close: () => {},
|
|
94
|
-
} satisfies
|
|
94
|
+
} satisfies ThreadClient;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -133,7 +133,7 @@ describe("live working-tree diff for a non-diff thread", () => {
|
|
|
133
133
|
|
|
134
134
|
return { ...session, annotations: [{ ...annotation, createdAt: AT }] };
|
|
135
135
|
},
|
|
136
|
-
} satisfies
|
|
136
|
+
} satisfies ThreadClient;
|
|
137
137
|
const controller = createReviewController({
|
|
138
138
|
sessionId: session.id,
|
|
139
139
|
openClient: async () => client,
|
|
@@ -176,7 +176,7 @@ describe("live working-tree diff for a non-diff thread", () => {
|
|
|
176
176
|
...fakeClient(sessionA),
|
|
177
177
|
sessionList: async () => [sessionA, sessionB],
|
|
178
178
|
repoDiff: async (cwd: string) => (cwd === "/repoA" ? pendingA : diffB),
|
|
179
|
-
} satisfies
|
|
179
|
+
} satisfies ThreadClient;
|
|
180
180
|
|
|
181
181
|
const controller = createReviewController({
|
|
182
182
|
sessionId: sessionA.id,
|
|
@@ -228,7 +228,7 @@ function diffSession(meta: Thread["artifact"]["meta"], id = "ses_diff"): Thread
|
|
|
228
228
|
artifact: { type: "diff", content: STALE_PATCH, files: STALE_FILES, meta },
|
|
229
229
|
revisions: [{ revision: 1, content: STALE_PATCH, submittedAt: AT }],
|
|
230
230
|
annotations: [],
|
|
231
|
-
|
|
231
|
+
message: null,
|
|
232
232
|
status: "pending",
|
|
233
233
|
createdAt: AT,
|
|
234
234
|
};
|
|
@@ -258,7 +258,7 @@ describe("frozen vs live diff by thread kind", () => {
|
|
|
258
258
|
test("a plain diff review pins its captured snapshot and never queries the live tree", async () => {
|
|
259
259
|
const session = diffSession({});
|
|
260
260
|
const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
|
|
261
|
-
const client = { ...fakeClient(session), repoDiff } satisfies
|
|
261
|
+
const client = { ...fakeClient(session), repoDiff } satisfies ThreadClient;
|
|
262
262
|
const controller = createReviewController({
|
|
263
263
|
sessionId: session.id,
|
|
264
264
|
openClient: async () => client,
|
|
@@ -289,7 +289,7 @@ describe("serve mode pins the served thread to a frozen snapshot", () => {
|
|
|
289
289
|
test("the served artifact wins over the thread's own capture and the live tree", async () => {
|
|
290
290
|
const session = diffSession({ workbench: true });
|
|
291
291
|
const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
|
|
292
|
-
const client = { ...fakeClient(session), repoDiff } satisfies
|
|
292
|
+
const client = { ...fakeClient(session), repoDiff } satisfies ThreadClient;
|
|
293
293
|
const controller = createReviewController({
|
|
294
294
|
sessionId: session.id,
|
|
295
295
|
openClient: async () => client,
|
|
@@ -12,7 +12,7 @@ function threadFixture(annotations: Annotation[] = []): Thread {
|
|
|
12
12
|
{ revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
|
|
13
13
|
],
|
|
14
14
|
annotations,
|
|
15
|
-
|
|
15
|
+
message: null,
|
|
16
16
|
status: "pending",
|
|
17
17
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
18
18
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
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 ShareAccess, type Thread } from "@cueloop/schema";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
5
5
|
import {
|
|
6
6
|
createReviewController,
|
|
7
7
|
SHARE_RECONNECT_MAX_MS,
|
|
@@ -43,7 +43,7 @@ function sessionFixture(overrides: Partial<Thread> = {}): Thread {
|
|
|
43
43
|
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
44
44
|
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
45
45
|
annotations: [],
|
|
46
|
-
|
|
46
|
+
message: null,
|
|
47
47
|
status: "pending",
|
|
48
48
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
49
49
|
...overrides,
|
|
@@ -61,20 +61,20 @@ function annotation(id: string, author: string): Annotation {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
interface
|
|
65
|
-
sessionComment: Mock<
|
|
64
|
+
interface FakeThreadClient extends ThreadClient {
|
|
65
|
+
sessionComment: Mock<ThreadClient["sessionComment"]>;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const unimplemented = (member: string) => () =>
|
|
69
69
|
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
70
70
|
|
|
71
|
-
function fakeClient(session: Thread):
|
|
71
|
+
function fakeClient(session: Thread): FakeThreadClient {
|
|
72
72
|
return {
|
|
73
73
|
onEvent: () => () => {},
|
|
74
74
|
subscribe: async () => {},
|
|
75
75
|
sessionGet: async () => session,
|
|
76
76
|
sessionList: async () => [session],
|
|
77
|
-
sessionComment: mock<
|
|
77
|
+
sessionComment: mock<ThreadClient["sessionComment"]>(async () => session),
|
|
78
78
|
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
79
79
|
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
80
80
|
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
@@ -108,7 +108,7 @@ function fakeClient(session: Thread): FakeSessionClient {
|
|
|
108
108
|
}),
|
|
109
109
|
sessionDelete: unimplemented("sessionDelete"),
|
|
110
110
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
111
|
-
|
|
111
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
112
112
|
close: () => {},
|
|
113
113
|
};
|
|
114
114
|
}
|
|
@@ -119,7 +119,7 @@ async function connectedController(
|
|
|
119
119
|
session: Thread,
|
|
120
120
|
clock?: ManualClock,
|
|
121
121
|
transport: ShareTransport = shareTransport,
|
|
122
|
-
): Promise<{ controller: ReturnType<typeof createReviewController>; client:
|
|
122
|
+
): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeThreadClient }> {
|
|
123
123
|
const client = fakeClient(session);
|
|
124
124
|
const controller = createReviewController({
|
|
125
125
|
sessionId: session.id,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type Thread,
|
|
7
7
|
} from "@cueloop/schema";
|
|
8
8
|
import { curateDiff } from "@cueloop/daemon/curate";
|
|
9
|
-
import type {
|
|
9
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
10
10
|
import { createReviewController } from "./thread-controller";
|
|
11
11
|
|
|
12
12
|
const PATCH_A = `diff --git a/src/a.ts b/src/a.ts
|
|
@@ -39,7 +39,7 @@ function diffSession(id: string, patch: string, files: DiffFileContents[]): Thre
|
|
|
39
39
|
artifact: { type: "diff", content: patch, meta: { title: id }, files },
|
|
40
40
|
revisions: [{ revision: 1, content: patch, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
41
41
|
annotations: [],
|
|
42
|
-
|
|
42
|
+
message: null,
|
|
43
43
|
status: "pending",
|
|
44
44
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
45
45
|
};
|
|
@@ -66,7 +66,7 @@ const unimplemented = (member: string) => () =>
|
|
|
66
66
|
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
67
67
|
|
|
68
68
|
/** A fake client over a fixed set of sessions, so open(id) can switch between them from the inbox. */
|
|
69
|
-
function fakeClient(sessions: Thread[]):
|
|
69
|
+
function fakeClient(sessions: Thread[]): ThreadClient {
|
|
70
70
|
const byId = new Map(sessions.map((session) => [session.id, session]));
|
|
71
71
|
|
|
72
72
|
return {
|
|
@@ -108,7 +108,7 @@ function fakeClient(sessions: Thread[]): SessionClient {
|
|
|
108
108
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
109
109
|
sessionDelete: unimplemented("sessionDelete"),
|
|
110
110
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
111
|
-
|
|
111
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
112
112
|
close: () => {},
|
|
113
113
|
};
|
|
114
114
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type Annotation,
|
|
15
15
|
type Thread,
|
|
16
16
|
} from "@cueloop/schema";
|
|
17
|
-
import type {
|
|
17
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
18
18
|
import { createReviewController, type ShareTransport } from "./thread-controller";
|
|
19
19
|
import { mergeFromShare } from "./share";
|
|
20
20
|
|
|
@@ -39,7 +39,7 @@ function sessionFixture(): Thread {
|
|
|
39
39
|
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
40
40
|
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
|
|
41
41
|
annotations: [annotation("a1", "first")],
|
|
42
|
-
|
|
42
|
+
message: null,
|
|
43
43
|
status: "pending",
|
|
44
44
|
createdAt: AT,
|
|
45
45
|
};
|
|
@@ -101,9 +101,9 @@ function fakeClient(session: Thread) {
|
|
|
101
101
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
102
102
|
sessionDelete: unimplemented("sessionDelete"),
|
|
103
103
|
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
104
|
-
|
|
104
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
105
105
|
close: () => {},
|
|
106
|
-
} satisfies
|
|
106
|
+
} satisfies ThreadClient;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
package/src/thread-controller.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
11
11
|
import { createStore, type StoreApi } from "zustand/vanilla";
|
|
12
|
-
import { DaemonClient, type
|
|
12
|
+
import { DaemonClient, type ThreadClient } from "@cueloop/daemon/client";
|
|
13
13
|
import {
|
|
14
14
|
applyPathView,
|
|
15
15
|
createBranch,
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
type ShareLink,
|
|
37
37
|
type Thread,
|
|
38
38
|
type SessionHistory,
|
|
39
|
-
type
|
|
39
|
+
type MessageOutcome,
|
|
40
40
|
} from "@cueloop/schema";
|
|
41
41
|
import { loadBundledExporters, type BundledExporter } from "./integrations";
|
|
42
42
|
import {
|
|
@@ -140,7 +140,7 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
|
140
140
|
/**
|
|
141
141
|
* Whether two records of the same thread yield the same document projection (display, rows, files,
|
|
142
142
|
* models). Those derive only from the artifact content and the working copy, so an update that
|
|
143
|
-
* touched only annotations, status, or the
|
|
143
|
+
* touched only annotations, status, or the message leaves them identical - the projection can be
|
|
144
144
|
* reused rather than re-parsed. Content and working copy are strings, compared here by value.
|
|
145
145
|
*/
|
|
146
146
|
export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
|
|
@@ -238,7 +238,7 @@ export interface ReviewControllerOptions {
|
|
|
238
238
|
* daemon; the sharing gateway injects a blob-backed client so the same <App>
|
|
239
239
|
* renders a decrypted share instead.
|
|
240
240
|
*/
|
|
241
|
-
openClient?: () => Promise<
|
|
241
|
+
openClient?: () => Promise<ThreadClient>;
|
|
242
242
|
shareTransport?: ShareTransport;
|
|
243
243
|
/** Serve mode: pin this frozen diff onto the served thread so an observer sees a stable snapshot. */
|
|
244
244
|
servedArtifact?: Artifact;
|
|
@@ -359,7 +359,7 @@ export interface ReviewController {
|
|
|
359
359
|
/** Leave the walk; the viewed set stays on the session record. */
|
|
360
360
|
walkLeave(): void;
|
|
361
361
|
/** Resolve the review, run the export, start the completion hand-back. */
|
|
362
|
-
submit(
|
|
362
|
+
submit(message: MessageOutcome, summary: string): void;
|
|
363
363
|
/** Publish the current session as a public share link; the ssh line lands on the clipboard. */
|
|
364
364
|
share(): void;
|
|
365
365
|
/** Stop sharing the current session, revoking every link at the gateway. Owner only. */
|
|
@@ -434,7 +434,7 @@ const DERIVED_CACHE_LIMIT = 8;
|
|
|
434
434
|
|
|
435
435
|
class Controller implements ReviewController {
|
|
436
436
|
readonly readOnly: boolean;
|
|
437
|
-
private client:
|
|
437
|
+
private client: ThreadClient | null = null;
|
|
438
438
|
private closed = false;
|
|
439
439
|
/** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
|
|
440
440
|
private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
|
|
@@ -582,7 +582,7 @@ class Controller implements ReviewController {
|
|
|
582
582
|
/**
|
|
583
583
|
* The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
|
|
584
584
|
* files, and models derive only from the artifact and working copy - never from annotations,
|
|
585
|
-
* status, or the
|
|
585
|
+
* status, or the message - so an update that touched only those produces an all-new session
|
|
586
586
|
* record but an identical projection.
|
|
587
587
|
*/
|
|
588
588
|
private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
|
|
@@ -998,7 +998,7 @@ class Controller implements ReviewController {
|
|
|
998
998
|
const session = this.snapshot.session;
|
|
999
999
|
|
|
1000
1000
|
if (!session || session.status === "resolved") return null;
|
|
1001
|
-
if (!session.artifact.files) {
|
|
1001
|
+
if (!session.artifact.files?.length) {
|
|
1002
1002
|
this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
|
|
1003
1003
|
|
|
1004
1004
|
return null;
|
|
@@ -1503,7 +1503,7 @@ class Controller implements ReviewController {
|
|
|
1503
1503
|
if (this.snapshot.walk) this.update({ walk: null });
|
|
1504
1504
|
}
|
|
1505
1505
|
|
|
1506
|
-
submit(
|
|
1506
|
+
submit(message: MessageOutcome, summary: string): void {
|
|
1507
1507
|
const session = this.snapshot.session;
|
|
1508
1508
|
|
|
1509
1509
|
if (!session) return;
|
|
@@ -1511,14 +1511,14 @@ class Controller implements ReviewController {
|
|
|
1511
1511
|
const actionBodies = Object.fromEntries(
|
|
1512
1512
|
slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
|
|
1513
1513
|
);
|
|
1514
|
-
this.client!.
|
|
1514
|
+
this.client!.sessionSendMessage(session.id, message, summary, actionBodies)
|
|
1515
1515
|
.then((resolved) => {
|
|
1516
|
-
// The completion overlay heading already states the
|
|
1516
|
+
// The completion overlay heading already states the message, so the
|
|
1517
1517
|
// status line stays empty here - only export/error messages fill it.
|
|
1518
1518
|
this.update({ session: resolved, status: "" });
|
|
1519
1519
|
// notes-vault export: guarded by each exporter's policy (default manual = no-op)
|
|
1520
1520
|
for (const exporter of this.exporters) {
|
|
1521
|
-
if (!exporter.runsOn(
|
|
1521
|
+
if (!exporter.runsOn(message)) continue;
|
|
1522
1522
|
void exporter.run(resolved).then((exportResult) => {
|
|
1523
1523
|
this.setStatus(
|
|
1524
1524
|
exportResult.success && exportResult.path
|
package/src/tree-view.test.ts
CHANGED
package/src/tree-view.ts
CHANGED
|
@@ -55,10 +55,10 @@ export function describeEntry(entry: SessionEntry, round?: number): EntryDescrip
|
|
|
55
55
|
return { glyph: "·", text: "comment" };
|
|
56
56
|
case "comment-removed":
|
|
57
57
|
return { glyph: "·", text: "comment removed" };
|
|
58
|
-
case "
|
|
58
|
+
case "message":
|
|
59
59
|
return {
|
|
60
|
-
glyph: entry.
|
|
61
|
-
text: entry.
|
|
60
|
+
glyph: entry.message.outcome === "approved" ? "✓" : "✗",
|
|
61
|
+
text: entry.message.outcome.replace("_", " "),
|
|
62
62
|
};
|
|
63
63
|
case "branch-summary":
|
|
64
64
|
return { glyph: "↩", text: entry.text ? `"${entry.text}"` : "returned" };
|
|
@@ -87,7 +87,7 @@ export interface AnnotationSurfaceOptions {
|
|
|
87
87
|
* thread anchors comments in plan coordinates, so a draft here would misanchor.
|
|
88
88
|
*/
|
|
89
89
|
commentsEnabled?: boolean;
|
|
90
|
-
/** A
|
|
90
|
+
/** A message is in: no draft may open; the app answers with its read-only status. */
|
|
91
91
|
resolved: boolean;
|
|
92
92
|
/** True while a menu, dialog, or overlay owns the keyboard. */
|
|
93
93
|
suspended: boolean;
|