@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.78
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.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +14 -2
- package/src/App.export.test.tsx +1 -2
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-review.test.tsx +16 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.submit-confirm.test.tsx +34 -5
- package/src/App.test.tsx +2 -2
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +180 -5
- package/src/App.walk.test.tsx +2 -2
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +20 -3
- package/src/app-view-model.ts +4 -0
- package/src/components/AnnotatableFileView.tsx +10 -2
- package/src/components/AnnotationCards.tsx +40 -6
- 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.tsx +11 -12
- package/src/components/DiffContentView.tsx +9 -5
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +2 -0
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +122 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +80 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadTree.tsx +11 -6
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -1
- package/src/components/ThreadView.tsx +71 -8
- package/src/components/Toast.tsx +0 -5
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
- package/src/components/primitives/Card.tsx +3 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +8 -1
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +44 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +1 -1
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/run.ts +31 -19
- package/src/serve.ts +1 -1
- package/src/share.ts +11 -3
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- 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 +1 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.switch-cache.test.ts +146 -0
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +171 -24
- package/src/use-annotation-surface.tsx +53 -29
- package/src/use-settings-dialog.tsx +24 -0
|
@@ -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
|
+
});
|
|
@@ -73,6 +73,7 @@ function fakeClient(session: Thread): SessionClient {
|
|
|
73
73
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
74
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
75
|
sessionCurate: unimplemented("sessionCurate"),
|
|
76
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
76
77
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
78
|
sessionBranch: unimplemented("sessionBranch"),
|
|
78
79
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
2
|
import { ManualClock } from "@opentui/core/testing";
|
|
3
|
-
import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
|
|
3
|
+
import { SCHEMA_VERSION, type Annotation, type ShareAccess, type Thread } from "@cueloop/schema";
|
|
4
4
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
5
5
|
import {
|
|
6
6
|
createReviewController,
|
|
@@ -14,7 +14,11 @@ const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", cop
|
|
|
14
14
|
let remote: Thread;
|
|
15
15
|
const pullShare = mock(async () => remote);
|
|
16
16
|
const pushShare = mock(
|
|
17
|
-
async (
|
|
17
|
+
async (
|
|
18
|
+
_shareId: string,
|
|
19
|
+
_annotations: Array<Omit<Annotation, "createdAt">>,
|
|
20
|
+
_access?: ShareAccess,
|
|
21
|
+
) => {},
|
|
18
22
|
);
|
|
19
23
|
|
|
20
24
|
const shareTransport: ShareTransport = {
|
|
@@ -73,6 +77,7 @@ function fakeClient(session: Thread): FakeSessionClient {
|
|
|
73
77
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
78
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
79
|
sessionCurate: unimplemented("sessionCurate"),
|
|
80
|
+
sessionSetAccess: mock(async () => session),
|
|
76
81
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
82
|
sessionBranch: unimplemented("sessionBranch"),
|
|
78
83
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -149,6 +154,35 @@ describe("share", () => {
|
|
|
149
154
|
});
|
|
150
155
|
});
|
|
151
156
|
|
|
157
|
+
describe("setShareAccess", () => {
|
|
158
|
+
test("pushes the allowlist up to the gateway when the plan is already shared", async () => {
|
|
159
|
+
// Arrange
|
|
160
|
+
pushShare.mockClear();
|
|
161
|
+
const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
|
|
162
|
+
|
|
163
|
+
// Act
|
|
164
|
+
controller.setShareAccess(["octocat"]);
|
|
165
|
+
await tick();
|
|
166
|
+
|
|
167
|
+
// Assert - the existing link must enforce the new access, so it rides the push
|
|
168
|
+
expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
|
|
169
|
+
expect(pushShare.mock.calls[0]?.[2]).toEqual({ githubLogins: ["octocat"] });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("does not push when the plan was never shared", async () => {
|
|
173
|
+
// Arrange
|
|
174
|
+
pushShare.mockClear();
|
|
175
|
+
const { controller } = await connectedController(sessionFixture());
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
controller.setShareAccess(["octocat"]);
|
|
179
|
+
await tick();
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
152
186
|
describe("pullShared", () => {
|
|
153
187
|
test("unions collaborator notes and identities in when the plan was shared", async () => {
|
|
154
188
|
// Arrange
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SCHEMA_VERSION,
|
|
4
|
+
type DiffFileContents,
|
|
5
|
+
type HunkRejection,
|
|
6
|
+
type Thread,
|
|
7
|
+
} from "@cueloop/schema";
|
|
8
|
+
import { curateDiff } from "@cueloop/daemon/curate";
|
|
9
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { createReviewController } from "./thread-controller";
|
|
11
|
+
|
|
12
|
+
const PATCH_A = `diff --git a/src/a.ts b/src/a.ts
|
|
13
|
+
index 111..222 100644
|
|
14
|
+
--- a/src/a.ts
|
|
15
|
+
+++ b/src/a.ts
|
|
16
|
+
@@ -1,3 +1,3 @@
|
|
17
|
+
export class A {
|
|
18
|
+
- value = 1;
|
|
19
|
+
+ value = 2;
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const PATCH_B = `diff --git a/src/b.ts b/src/b.ts
|
|
24
|
+
index 333..444 100644
|
|
25
|
+
--- a/src/b.ts
|
|
26
|
+
+++ b/src/b.ts
|
|
27
|
+
@@ -1,3 +1,3 @@
|
|
28
|
+
export class B {
|
|
29
|
+
- flag = false;
|
|
30
|
+
+ flag = true;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function diffSession(id: string, patch: string, files: DiffFileContents[]): Thread {
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: SCHEMA_VERSION,
|
|
37
|
+
id,
|
|
38
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
+
artifact: { type: "diff", content: patch, meta: { title: id }, files },
|
|
40
|
+
revisions: [{ revision: 1, content: patch, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
41
|
+
annotations: [],
|
|
42
|
+
verdict: null,
|
|
43
|
+
status: "pending",
|
|
44
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FILES_A: DiffFileContents[] = [
|
|
49
|
+
{
|
|
50
|
+
path: "src/a.ts",
|
|
51
|
+
oldContents: "export class A {\n value = 1;\n}\n",
|
|
52
|
+
newContents: "export class A {\n value = 2;\n}\n",
|
|
53
|
+
status: "modified",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
const FILES_B: DiffFileContents[] = [
|
|
57
|
+
{
|
|
58
|
+
path: "src/b.ts",
|
|
59
|
+
oldContents: "export class B {\n flag = false;\n}\n",
|
|
60
|
+
newContents: "export class B {\n flag = true;\n}\n",
|
|
61
|
+
status: "modified",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const unimplemented = (member: string) => () =>
|
|
66
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
67
|
+
|
|
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[]): SessionClient {
|
|
70
|
+
const byId = new Map(sessions.map((session) => [session.id, session]));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
onEvent: () => () => {},
|
|
74
|
+
subscribe: async () => {},
|
|
75
|
+
sessionGet: async (id: string) => byId.get(id)!,
|
|
76
|
+
sessionList: async () => [...byId.values()],
|
|
77
|
+
sessionCurate: mock(async (id: string, rejections: HunkRejection[]) => {
|
|
78
|
+
const session = byId.get(id)!;
|
|
79
|
+
const content = rejections.length
|
|
80
|
+
? curateDiff(session.artifact.files!, rejections)
|
|
81
|
+
: undefined;
|
|
82
|
+
const next = {
|
|
83
|
+
...session,
|
|
84
|
+
workingCopy: content,
|
|
85
|
+
curation: rejections.length ? rejections : undefined,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
byId.set(id, next);
|
|
89
|
+
|
|
90
|
+
return next;
|
|
91
|
+
}),
|
|
92
|
+
sessionComment: unimplemented("sessionComment"),
|
|
93
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
94
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
95
|
+
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
96
|
+
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
97
|
+
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
98
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
99
|
+
sessionNavigate: unimplemented("sessionNavigate"),
|
|
100
|
+
sessionBranch: unimplemented("sessionBranch"),
|
|
101
|
+
sessionSwitch: unimplemented("sessionSwitch"),
|
|
102
|
+
sessionLabel: unimplemented("sessionLabel"),
|
|
103
|
+
sessionFork: unimplemented("sessionFork"),
|
|
104
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
105
|
+
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
106
|
+
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
107
|
+
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
108
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
109
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
110
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
111
|
+
close: () => {},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
116
|
+
|
|
117
|
+
// rows for each single-change patch: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
|
|
118
|
+
describe("switching threads keeps the cached projection curatable", () => {
|
|
119
|
+
test("curation still works after switching away and back to a diff thread", async () => {
|
|
120
|
+
// Arrange - two diff threads in one inbox
|
|
121
|
+
const controller = createReviewController({
|
|
122
|
+
sessionId: "ses_a",
|
|
123
|
+
openClient: async () =>
|
|
124
|
+
fakeClient([
|
|
125
|
+
diffSession("ses_a", PATCH_A, FILES_A),
|
|
126
|
+
diffSession("ses_b", PATCH_B, FILES_B),
|
|
127
|
+
]),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
controller.connect();
|
|
131
|
+
await tick();
|
|
132
|
+
|
|
133
|
+
// Act - visit B, then return to A (A's projection now comes from the per-thread cache)
|
|
134
|
+
controller.open("ses_b");
|
|
135
|
+
await tick();
|
|
136
|
+
controller.open("ses_a");
|
|
137
|
+
await tick();
|
|
138
|
+
|
|
139
|
+
// Assert - the returned thread's file model parses lazily and curation lands
|
|
140
|
+
controller.toggleRejectChange(4);
|
|
141
|
+
await tick();
|
|
142
|
+
|
|
143
|
+
expect(controller.getSnapshot().status).toContain("change rejected");
|
|
144
|
+
expect(controller.getSnapshot().session?.workingCopy).toBe("");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -82,6 +82,7 @@ function fakeClient(session: Thread) {
|
|
|
82
82
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
83
83
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
84
84
|
sessionCurate: unimplemented("sessionCurate"),
|
|
85
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
85
86
|
sessionNavigate: mock(pending),
|
|
86
87
|
sessionBranch: mock(pending),
|
|
87
88
|
sessionSwitch: mock(pending),
|
package/src/thread-controller.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The thread controller: every daemon round-trip and mutation
|
|
3
3
|
* primitive behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
4
|
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
5
|
* primitives (cut/edit/annotate/submit/...), and the post-submit completion
|
|
@@ -132,6 +132,41 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
|
132
132
|
return `plan:${base.lineStart}-${base.lineEnd}`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether two records of the same thread yield the same document projection (display, rows, files,
|
|
137
|
+
* models). Those derive only from the artifact content and the working copy, so an update that
|
|
138
|
+
* touched only annotations, status, or the verdict leaves them identical - the projection can be
|
|
139
|
+
* reused rather than re-parsed. Content and working copy are strings, compared here by value.
|
|
140
|
+
*/
|
|
141
|
+
export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
|
|
142
|
+
return (
|
|
143
|
+
previous.id === next.id &&
|
|
144
|
+
previous.artifact.content === next.artifact.content &&
|
|
145
|
+
previous.workingCopy === next.workingCopy
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The session with `annotation` upserted, for an optimistic paint the instant the composer closes
|
|
151
|
+
* rather than after the daemon round-trip - otherwise a saved comment blinks out until the write
|
|
152
|
+
* lands. An edit keeps the note in place and its timestamp; a new note appends. The daemon's record
|
|
153
|
+
* replaces this guess when the write returns.
|
|
154
|
+
*/
|
|
155
|
+
export function withAnnotationUpserted(
|
|
156
|
+
session: Thread,
|
|
157
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
158
|
+
): Thread {
|
|
159
|
+
const index = session.annotations.findIndex((entry) => entry.id === annotation.id);
|
|
160
|
+
const createdAt = index >= 0 ? session.annotations[index]!.createdAt : new Date().toISOString();
|
|
161
|
+
const optimistic: Annotation = { ...annotation, createdAt };
|
|
162
|
+
const annotations =
|
|
163
|
+
index >= 0
|
|
164
|
+
? session.annotations.map((entry) => (entry.id === annotation.id ? optimistic : entry))
|
|
165
|
+
: [...session.annotations, optimistic];
|
|
166
|
+
|
|
167
|
+
return { ...session, annotations };
|
|
168
|
+
}
|
|
169
|
+
|
|
135
170
|
export interface ControllerSnapshot {
|
|
136
171
|
session: Thread | null;
|
|
137
172
|
inbox: Thread[] | null;
|
|
@@ -309,6 +344,8 @@ export interface ReviewController {
|
|
|
309
344
|
submit(verdict: VerdictKind, summary: string): void;
|
|
310
345
|
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
311
346
|
share(): void;
|
|
347
|
+
/** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
|
|
348
|
+
setShareAccess(githubLogins: string[]): void;
|
|
312
349
|
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
313
350
|
treeRows(): TreeRow[];
|
|
314
351
|
/**
|
|
@@ -343,6 +380,9 @@ interface DerivedSessionProjection {
|
|
|
343
380
|
display: DisplayBlock[];
|
|
344
381
|
rows: DiffRow[];
|
|
345
382
|
files: WalkFile[];
|
|
383
|
+
/** Raw per-file contents; parsed into a model lazily on first curation touch, not all upfront. */
|
|
384
|
+
fileContents: Map<string, DiffFileContents>;
|
|
385
|
+
/** Parsed file models, filled on demand from fileContents and memoized here. */
|
|
346
386
|
models: Map<string, FileDiffMetadata>;
|
|
347
387
|
tree: TreeRow[];
|
|
348
388
|
}
|
|
@@ -353,6 +393,15 @@ interface LiveWorkingDiff {
|
|
|
353
393
|
files: readonly DiffFileContents[];
|
|
354
394
|
}
|
|
355
395
|
|
|
396
|
+
interface DerivedCacheEntry {
|
|
397
|
+
derivedFor: Thread;
|
|
398
|
+
derivedForLiveDiff: LiveWorkingDiff | null;
|
|
399
|
+
derived: DerivedSessionProjection;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Recently-viewed threads whose parsed projection is kept for an instant return; oldest evicted first. */
|
|
403
|
+
const DERIVED_CACHE_LIMIT = 8;
|
|
404
|
+
|
|
356
405
|
class Controller implements ReviewController {
|
|
357
406
|
readonly readOnly: boolean;
|
|
358
407
|
private client: SessionClient | null = null;
|
|
@@ -387,9 +436,12 @@ class Controller implements ReviewController {
|
|
|
387
436
|
display: [],
|
|
388
437
|
rows: [],
|
|
389
438
|
files: [],
|
|
439
|
+
fileContents: new Map(),
|
|
390
440
|
models: new Map(),
|
|
391
441
|
tree: [],
|
|
392
442
|
};
|
|
443
|
+
/** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
|
|
444
|
+
private derivedCache = new Map<string, DerivedCacheEntry>();
|
|
393
445
|
/** File paths whose diff body is folded to just the file band. */
|
|
394
446
|
private collapsedFiles = new Set<string>();
|
|
395
447
|
/** File paths woven to their full contents (unchanged lines shown as context). */
|
|
@@ -496,6 +548,57 @@ class Controller implements ReviewController {
|
|
|
496
548
|
}
|
|
497
549
|
|
|
498
550
|
// ── derived projections ─────────────────────
|
|
551
|
+
/**
|
|
552
|
+
* The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
|
|
553
|
+
* files, and models derive only from the artifact and working copy - never from annotations,
|
|
554
|
+
* status, or the verdict - so an update that touched only those produces an all-new session
|
|
555
|
+
* record but an identical projection.
|
|
556
|
+
*/
|
|
557
|
+
private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
|
|
558
|
+
return (
|
|
559
|
+
this.derivedFor !== null &&
|
|
560
|
+
this.derivedForLiveDiff === liveDiff &&
|
|
561
|
+
sameDerivationInputs(this.derivedFor, session)
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private buildDerived(
|
|
566
|
+
session: Thread | null,
|
|
567
|
+
frozenDiff: boolean,
|
|
568
|
+
liveDiff: LiveWorkingDiff | null,
|
|
569
|
+
): DerivedSessionProjection {
|
|
570
|
+
const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
|
|
571
|
+
const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
|
|
572
|
+
const rows = content ? diffRows(content) : [];
|
|
573
|
+
const fileContents = new Map<string, DiffFileContents>();
|
|
574
|
+
|
|
575
|
+
for (const file of files) fileContents.set(file.path, file);
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
579
|
+
rows,
|
|
580
|
+
files: walkFiles(rows),
|
|
581
|
+
fileContents,
|
|
582
|
+
models: new Map<string, FileDiffMetadata>(),
|
|
583
|
+
tree: session?.history ? treeRows(session.history) : [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The parsed model for a changed file, computed on first touch and memoized in the projection. */
|
|
588
|
+
private fileModel(path: string): FileDiffMetadata | undefined {
|
|
589
|
+
const cached = this.derived.models.get(path);
|
|
590
|
+
|
|
591
|
+
if (cached) return cached;
|
|
592
|
+
const contents = this.derived.fileContents.get(path);
|
|
593
|
+
|
|
594
|
+
if (!contents) return undefined;
|
|
595
|
+
const model = parseFileDiff(contents);
|
|
596
|
+
|
|
597
|
+
this.derived.models.set(path, model);
|
|
598
|
+
|
|
599
|
+
return model;
|
|
600
|
+
}
|
|
601
|
+
|
|
499
602
|
private ensureDerived(): void {
|
|
500
603
|
const session = this.snapshot.session;
|
|
501
604
|
const frozenDiff = readsFrozenDiff(session);
|
|
@@ -503,27 +606,60 @@ class Controller implements ReviewController {
|
|
|
503
606
|
const liveDiff = frozenDiff ? null : this.liveDiff;
|
|
504
607
|
|
|
505
608
|
if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
|
|
506
|
-
//
|
|
507
|
-
if (
|
|
609
|
+
// reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
|
|
610
|
+
if (session !== null && this.reusesDerived(session, liveDiff)) {
|
|
611
|
+
this.derivedFor = session;
|
|
612
|
+
this.derived = { ...this.derived, tree: session.history ? treeRows(session.history) : [] };
|
|
613
|
+
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
|
|
617
|
+
if (this.derivedFor?.id !== session?.id) {
|
|
508
618
|
this.collapsedFiles.clear();
|
|
509
619
|
this.expandedFiles.clear();
|
|
510
620
|
}
|
|
621
|
+
// returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
|
|
622
|
+
const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
|
|
623
|
+
|
|
624
|
+
if (
|
|
625
|
+
cached &&
|
|
626
|
+
cached.derivedForLiveDiff === liveDiff &&
|
|
627
|
+
sameDerivationInputs(cached.derivedFor, session!)
|
|
628
|
+
) {
|
|
629
|
+
this.derivedFor = session;
|
|
630
|
+
this.derivedForLiveDiff = liveDiff;
|
|
631
|
+
this.derived = {
|
|
632
|
+
...cached.derived,
|
|
633
|
+
tree: session!.history ? treeRows(session!.history) : [],
|
|
634
|
+
};
|
|
635
|
+
this.rememberDerived(session!, liveDiff, this.derived);
|
|
636
|
+
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
511
639
|
this.derivedFor = session;
|
|
512
640
|
this.derivedForLiveDiff = liveDiff;
|
|
641
|
+
this.derived = this.buildDerived(session, frozenDiff, liveDiff);
|
|
642
|
+
if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
|
|
643
|
+
}
|
|
513
644
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
645
|
+
/** Keep the freshest projections per thread, evicting the least-recently-used past the cap. */
|
|
646
|
+
private rememberDerived(
|
|
647
|
+
session: Thread,
|
|
648
|
+
liveDiff: LiveWorkingDiff | null,
|
|
649
|
+
derived: DerivedSessionProjection,
|
|
650
|
+
): void {
|
|
651
|
+
this.derivedCache.delete(session.id);
|
|
652
|
+
this.derivedCache.set(session.id, {
|
|
653
|
+
derivedFor: session,
|
|
654
|
+
derivedForLiveDiff: liveDiff,
|
|
655
|
+
derived,
|
|
656
|
+
});
|
|
657
|
+
while (this.derivedCache.size > DERIVED_CACHE_LIMIT) {
|
|
658
|
+
const oldest = this.derivedCache.keys().next().value;
|
|
518
659
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
rows,
|
|
523
|
-
files: walkFiles(rows),
|
|
524
|
-
models,
|
|
525
|
-
tree: session?.history ? treeRows(session.history) : [],
|
|
526
|
-
};
|
|
660
|
+
if (oldest === undefined) break;
|
|
661
|
+
this.derivedCache.delete(oldest);
|
|
662
|
+
}
|
|
527
663
|
}
|
|
528
664
|
|
|
529
665
|
/** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
|
|
@@ -819,7 +955,7 @@ class Controller implements ReviewController {
|
|
|
819
955
|
|
|
820
956
|
return null;
|
|
821
957
|
}
|
|
822
|
-
const model = this.
|
|
958
|
+
const model = this.fileModel(row.file);
|
|
823
959
|
|
|
824
960
|
if (!model) return null;
|
|
825
961
|
|
|
@@ -882,13 +1018,25 @@ class Controller implements ReviewController {
|
|
|
882
1018
|
this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
|
|
883
1019
|
}
|
|
884
1020
|
|
|
1021
|
+
setShareAccess(githubLogins: string[]): void {
|
|
1022
|
+
const session = this.snapshot.session;
|
|
1023
|
+
|
|
1024
|
+
if (!session) return;
|
|
1025
|
+
const access = { githubLogins };
|
|
1026
|
+
const expected: Thread = { ...session, access };
|
|
1027
|
+
|
|
1028
|
+
this.applyOptimistic(expected, this.client!.sessionSetAccess(session.id, githubLogins));
|
|
1029
|
+
// an already-shared link must enforce the new access, so push it up to the gateway blob
|
|
1030
|
+
if (session.shareId) void this.shareTransport.push(session.shareId, [], access).catch(() => {});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
885
1033
|
rejectedRows(): Set<number> {
|
|
886
1034
|
this.ensureDerived();
|
|
887
1035
|
if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
|
|
888
1036
|
const rejected = new Set<number>();
|
|
889
1037
|
|
|
890
1038
|
this.derived.rows.forEach((row, index) => {
|
|
891
|
-
const model = this.
|
|
1039
|
+
const model = this.fileModel(row.file);
|
|
892
1040
|
|
|
893
1041
|
if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
|
|
894
1042
|
});
|
|
@@ -965,7 +1113,7 @@ class Controller implements ReviewController {
|
|
|
965
1113
|
|
|
966
1114
|
/** The change/deletion rows a rejection covers, for its preview and reveal row. */
|
|
967
1115
|
private rowsForRejection(rejection: HunkRejection): DiffRow[] {
|
|
968
|
-
const model = this.
|
|
1116
|
+
const model = this.fileModel(rejection.path);
|
|
969
1117
|
|
|
970
1118
|
if (!model) return [];
|
|
971
1119
|
const rows: DiffRow[] = [];
|
|
@@ -1080,7 +1228,7 @@ class Controller implements ReviewController {
|
|
|
1080
1228
|
: { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
|
|
1081
1229
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1082
1230
|
|
|
1083
|
-
this.
|
|
1231
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1084
1232
|
this.mirrorAnnotation(persisted, wire);
|
|
1085
1233
|
|
|
1086
1234
|
return wire.id;
|
|
@@ -1096,7 +1244,7 @@ class Controller implements ReviewController {
|
|
|
1096
1244
|
: { id: newAnnotationId(), kind: "comment", anchor, body, target };
|
|
1097
1245
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1098
1246
|
|
|
1099
|
-
this.
|
|
1247
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1100
1248
|
this.mirrorAnnotation(persisted, wire);
|
|
1101
1249
|
|
|
1102
1250
|
return wire.id;
|
|
@@ -1162,7 +1310,7 @@ class Controller implements ReviewController {
|
|
|
1162
1310
|
const wire = root.target ? { ...base, target: root.target } : base;
|
|
1163
1311
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1164
1312
|
|
|
1165
|
-
this.
|
|
1313
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1166
1314
|
this.mirrorAnnotation(persisted, wire);
|
|
1167
1315
|
|
|
1168
1316
|
return wire.id;
|
|
@@ -1176,7 +1324,7 @@ class Controller implements ReviewController {
|
|
|
1176
1324
|
const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
|
|
1177
1325
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1178
1326
|
|
|
1179
|
-
this.
|
|
1327
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1180
1328
|
this.mirrorAnnotation(persisted, wire);
|
|
1181
1329
|
|
|
1182
1330
|
return wire.id;
|
|
@@ -1201,9 +1349,8 @@ class Controller implements ReviewController {
|
|
|
1201
1349
|
if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
|
|
1202
1350
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1203
1351
|
|
|
1204
|
-
this.
|
|
1352
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1205
1353
|
this.mirrorAnnotation(persisted, wire);
|
|
1206
|
-
this.setStatus("annotation updated");
|
|
1207
1354
|
}
|
|
1208
1355
|
|
|
1209
1356
|
// push only after the local write lands, so a rejected write never leaks to the share
|