@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.
Files changed (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.identity.test.tsx +78 -0
  6. package/src/App.plan-review.test.tsx +16 -0
  7. package/src/App.share-choice.test.tsx +105 -0
  8. package/src/App.submit-confirm.test.tsx +34 -5
  9. package/src/App.test.tsx +2 -2
  10. package/src/App.toast.test.tsx +3 -1
  11. package/src/App.tsx +180 -5
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  14. package/src/ansi-screen.test.ts +28 -0
  15. package/src/ansi-screen.ts +72 -0
  16. package/src/app-screens.tsx +20 -3
  17. package/src/app-view-model.ts +4 -0
  18. package/src/components/AnnotatableFileView.tsx +10 -2
  19. package/src/components/AnnotationCards.tsx +40 -6
  20. package/src/components/AnsiScreen.stories.tsx +12 -0
  21. package/src/components/AnsiScreen.tsx +28 -0
  22. package/src/components/CompletionOverlay.stories.tsx +15 -1
  23. package/src/components/CompletionOverlay.tsx +22 -5
  24. package/src/components/ConfirmCard.stories.tsx +2 -0
  25. package/src/components/ConfirmCard.tsx +45 -18
  26. package/src/components/ConfirmDialog.tsx +11 -12
  27. package/src/components/DiffContentView.tsx +9 -5
  28. package/src/components/FileContentsView.tsx +1 -1
  29. package/src/components/GridTabContent.tsx +2 -0
  30. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  31. package/src/components/ManageAccessDialog.test.tsx +94 -0
  32. package/src/components/ManageAccessDialog.tsx +122 -0
  33. package/src/components/PromptDialog.test.tsx +2 -2
  34. package/src/components/PromptDialog.tsx +9 -10
  35. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  36. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  37. package/src/components/ShareChoiceDialog.tsx +80 -0
  38. package/src/components/SlashComposer.stories.tsx +37 -0
  39. package/src/components/SlashComposer.tsx +87 -0
  40. package/src/components/ThreadTree.tsx +11 -6
  41. package/src/components/ThreadView.selection.test.tsx +88 -1
  42. package/src/components/ThreadView.slash.test.tsx +0 -1
  43. package/src/components/ThreadView.tsx +71 -8
  44. package/src/components/Toast.tsx +0 -5
  45. package/src/components/WalkWizard.stories.tsx +2 -2
  46. package/src/components/WalkWizard.tsx +10 -12
  47. package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
  48. package/src/components/primitives/Card.tsx +3 -0
  49. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  50. package/src/components/primitives/DialogActions.tsx +39 -0
  51. package/src/components/session-tree.ts +7 -6
  52. package/src/components/stories-app.tsx +13 -4
  53. package/src/components/stories.test.tsx +8 -1
  54. package/src/components/story-tree.test.ts +32 -24
  55. package/src/components/story.ts +32 -15
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +44 -1
  58. package/src/diff-intraline.test.ts +75 -2
  59. package/src/diff-intraline.ts +95 -26
  60. package/src/github-identity.test.ts +58 -0
  61. package/src/github-identity.ts +41 -0
  62. package/src/intent-dispatch.test.ts +17 -2
  63. package/src/intent-dispatch.ts +7 -1
  64. package/src/keymap.test.ts +2 -2
  65. package/src/keymap.ts +1 -1
  66. package/src/pasted-image.test.ts +67 -0
  67. package/src/pasted-image.ts +60 -0
  68. package/src/run.ts +31 -19
  69. package/src/serve.ts +1 -1
  70. package/src/share.ts +11 -3
  71. package/src/slash-palette.test.ts +17 -0
  72. package/src/slash-palette.ts +11 -0
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.derivation.test.ts +62 -0
  75. package/src/thread-controller.live-diff.test.ts +1 -0
  76. package/src/thread-controller.optimistic.test.ts +69 -0
  77. package/src/thread-controller.share.test.ts +36 -2
  78. package/src/thread-controller.switch-cache.test.ts +146 -0
  79. package/src/thread-controller.tree.test.ts +1 -0
  80. package/src/thread-controller.ts +171 -24
  81. package/src/use-annotation-surface.tsx +53 -29
  82. 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 (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
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),
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The review-session controller: every daemon round-trip and mutation
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
- // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
507
- if (this.derivedFor !== session) {
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
- const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
515
- const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
516
- const rows = content ? diffRows(content) : [];
517
- const models = new Map<string, FileDiffMetadata>();
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
- for (const file of files) models.set(file.path, parseFileDiff(file));
520
- this.derived = {
521
- display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
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.derived.models.get(row.file);
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.derived.models.get(row.file);
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.derived.models.get(rejection.path);
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.apply(persisted);
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.apply(persisted);
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.apply(persisted);
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.apply(persisted);
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.apply(persisted);
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