@cueloop/client 0.1.0-alpha.97 → 0.1.0-alpha.99

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 (76) hide show
  1. package/package.json +11 -11
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +31 -8
  4. package/src/annotations/use-annotation-surface.tsx +257 -31
  5. package/src/app/App.deleted-scroll.test.tsx +1 -0
  6. package/src/app/App.diff.test.tsx +1 -0
  7. package/src/app/App.identity.test.tsx +1 -0
  8. package/src/app/App.regression.test.tsx +5 -0
  9. package/src/app/App.share-dialog.test.tsx +5 -0
  10. package/src/app/App.submit-confirm.test.tsx +3 -0
  11. package/src/app/App.test.tsx +1 -0
  12. package/src/app/App.thread-agent.test.ts +12 -0
  13. package/src/app/App.tsx +223 -97
  14. package/src/app/App.visual.test.tsx +2 -0
  15. package/src/app/App.workbench.test.tsx +53 -0
  16. package/src/app/app-screens.tsx +3 -0
  17. package/src/app/components/AppShell.tsx +1 -0
  18. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  19. package/src/app/intent-dispatch.test.ts +1 -0
  20. package/src/app/run.ts +1 -0
  21. package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
  22. package/src/diff/components/AnnotatableFileView.tsx +8 -0
  23. package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
  24. package/src/diff/components/DiffContentView.tsx +10 -0
  25. package/src/diff/diff-intraline.test.ts +1 -0
  26. package/src/diff/diff-intraline.ts +2 -0
  27. package/src/diff/split-diff.ts +2 -0
  28. package/src/integrations/components/ShareDialog.tsx +1 -0
  29. package/src/markdown/components/ThreadView.tsx +62 -16
  30. package/src/markdown/mark-runs.ts +1 -0
  31. package/src/markdown/markdown-highlight.ts +2 -0
  32. package/src/performance/otlp-trace.test.ts +3 -0
  33. package/src/performance/otlp-trace.ts +4 -0
  34. package/src/performance/perf-report.ts +1 -0
  35. package/src/performance/perf-timings.ts +1 -0
  36. package/src/settings/components/quick-actions-editor.test.tsx +3 -0
  37. package/src/settings/config.test.ts +15 -0
  38. package/src/settings/config.ts +14 -4
  39. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  40. package/src/stories/stories-app.tsx +3 -0
  41. package/src/stories/story.ts +2 -0
  42. package/src/terminal/pty.ts +1 -0
  43. package/src/thread/agent-continuation.test.ts +32 -0
  44. package/src/thread/agent-continuation.ts +13 -0
  45. package/src/thread/agent-transcript.test.ts +161 -0
  46. package/src/thread/agent-transcript.ts +336 -0
  47. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  48. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  49. package/src/thread/components/AgentConfigControls.tsx +114 -0
  50. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  51. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  52. package/src/thread/components/AgentThreadPane.tsx +367 -0
  53. package/src/thread/components/ThreadFooter.test.tsx +2 -0
  54. package/src/thread/components/ThreadFooter.tsx +3 -0
  55. package/src/thread/components/ThreadTree.test.tsx +41 -0
  56. package/src/thread/components/ThreadTree.tsx +25 -5
  57. package/src/thread/components/thread-tree.test.ts +3 -0
  58. package/src/thread/components/thread-tree.ts +4 -0
  59. package/src/thread/thread-controller.curate.test.ts +1 -0
  60. package/src/thread/thread-controller.live-diff.test.ts +7 -0
  61. package/src/thread/thread-controller.share.test.ts +1 -0
  62. package/src/thread/thread-controller.ts +42 -0
  63. package/src/thread/use-thread-agent.test.tsx +208 -0
  64. package/src/thread/use-thread-agent.ts +111 -0
  65. package/src/ui/components/PromptDialog.test.tsx +4 -0
  66. package/src/ui/components/RootOverlay.tsx +1 -0
  67. package/src/ui/components/primitives/Dialog.test.tsx +1 -0
  68. package/src/workbench/components/ChangesColumn.tsx +1 -0
  69. package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
  70. package/src/workbench/components/FileContentsView.tsx +2 -0
  71. package/src/workbench/components/GridTabContent.tsx +7 -0
  72. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  73. package/src/workbench/components/ProjectTreeView.tsx +1 -0
  74. package/src/workbench/components/editor-grid.test.ts +11 -0
  75. package/src/workbench/components/editor-grid.ts +10 -0
  76. package/src/workbench/use-changes-workbench.ts +5 -0
@@ -324,6 +324,8 @@ export interface ReviewController {
324
324
  refreshDiff(): Promise<void>;
325
325
  /** Open a session from the inbox. */
326
326
  open(id: string): void;
327
+ /** Create and open an empty local Thread for a new conversation. */
328
+ createEmptyThread(): Promise<void>;
327
329
  /** Delete a session for good (inbox delete); the inbox refreshes on the event. */
328
330
  deleteSession(id: string): void;
329
331
  /** Rename a session's title; the inbox refreshes on the event. */
@@ -386,6 +388,7 @@ export interface ReviewController {
386
388
  annotatePrototype(selector: string, quote: string, body: string): string | undefined;
387
389
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
388
390
  updateAnnotation(id: string, body: string): void;
391
+ flushMutations?(): Promise<void>;
389
392
  removeAnnotation(id: string): void;
390
393
  setWorkingCopy(content: string | undefined): void;
391
394
  /** Enter the guided walk at the first unviewed file (diff sessions). */
@@ -472,6 +475,7 @@ const DERIVED_CACHE_LIMIT = 8;
472
475
  class Controller implements ReviewController {
473
476
  readonly readOnly: boolean;
474
477
  private client: ThreadClient | null = null;
478
+ private pendingMutations = new Set<Promise<Thread>>();
475
479
  private closed = false;
476
480
  /** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
477
481
  private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
@@ -825,6 +829,7 @@ class Controller implements ReviewController {
825
829
 
826
830
  async projectFiles(): Promise<string[]> {
827
831
  const session = this.snapshot.session;
832
+
828
833
  if (this.client?.projectFiles === undefined || !session) return [];
829
834
 
830
835
  return this.client.projectFiles(session.id);
@@ -832,6 +837,7 @@ class Controller implements ReviewController {
832
837
 
833
838
  async readFile(path: string): Promise<string | null> {
834
839
  const session = this.snapshot.session;
840
+
835
841
  if (this.client?.fileContents === undefined || !session) return null;
836
842
 
837
843
  return this.client.fileContents(session.id, path);
@@ -857,6 +863,7 @@ class Controller implements ReviewController {
857
863
  async repoChanges(): Promise<readonly DiffFileContents[]> {
858
864
  // a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
859
865
  const session = this.snapshot.session;
866
+
860
867
  if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
861
868
  const initialFiles = this.consumeInitialDiffFiles(session);
862
869
 
@@ -867,6 +874,7 @@ class Controller implements ReviewController {
867
874
  this.sidebarRepoRoot(),
868
875
  session?.artifact.meta.workbench ? (session.artifact.meta.vcs ?? "git") : undefined,
869
876
  );
877
+
870
878
  // a thread switch during the request would let this response overwrite the new thread's diff,
871
879
  // showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
872
880
  // re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
@@ -881,6 +889,7 @@ class Controller implements ReviewController {
881
889
  file.oldContents !== diff.files[index]?.oldContents ||
882
890
  file.newContents !== diff.files[index]?.newContents,
883
891
  );
892
+
884
893
  if (changed) {
885
894
  this.liveDiff = diff;
886
895
  // rows() derive from the fresh patch; re-render so an open diff tab repaints
@@ -956,7 +965,14 @@ class Controller implements ReviewController {
956
965
  * builds on the first instead of on the stale snapshot. Answers arrive in
957
966
  * request order over the socket and replace the guess with the daemon's copy.
958
967
  */
968
+ /** Agent invocation waits until the preceding comments reach the daemon. */
969
+ async flushMutations(): Promise<void> {
970
+ await Promise.all(this.pendingMutations);
971
+ }
972
+
959
973
  private applyOptimistic(expected: Thread, mutation: Promise<Thread>): void {
974
+ this.pendingMutations.add(mutation);
975
+ void mutation.finally(() => this.pendingMutations.delete(mutation)).catch(() => {});
960
976
  this.update({ session: expected });
961
977
  mutation
962
978
  .then((session) => this.update({ session }))
@@ -980,6 +996,30 @@ class Controller implements ReviewController {
980
996
  void this.refreshSession(id);
981
997
  }
982
998
 
999
+ async createEmptyThread(): Promise<void> {
1000
+ if (this.readOnly || !this.client?.sessionCreate) return;
1001
+ const workspace = this.snapshot.session?.workspace ?? {
1002
+ repoRoot: this.options.cwd ?? process.cwd(),
1003
+ branch: "detached",
1004
+ };
1005
+
1006
+ try {
1007
+ const thread = await this.client.sessionCreate(workspace, {
1008
+ type: "plan",
1009
+ content: "",
1010
+ meta: { title: "New Thread" },
1011
+ });
1012
+
1013
+ this.locallyViewed.clear();
1014
+ this.viewingId = thread.id;
1015
+ this.update({ session: thread });
1016
+ } catch (cause) {
1017
+ this.setStatus(
1018
+ `New Thread failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1019
+ );
1020
+ }
1021
+ }
1022
+
983
1023
  deleteSession(id: string): void {
984
1024
  if (this.readOnly) return this.setStatus("observer - read-only");
985
1025
  // a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
@@ -1703,6 +1743,7 @@ class Controller implements ReviewController {
1703
1743
  const actionBodies = Object.fromEntries(
1704
1744
  slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
1705
1745
  );
1746
+
1706
1747
  this.client!.sessionSendMessage(session.id, message, summary, actionBodies)
1707
1748
  .then((resolved) => {
1708
1749
  // The completion overlay heading already states the message, so the
@@ -1801,6 +1842,7 @@ class Controller implements ReviewController {
1801
1842
 
1802
1843
  if (!links.some((link) => link.id === id)) return;
1803
1844
  const allowlist = input.requireAuth ? input.allowlist : [];
1845
+
1804
1846
  // re-push the link's access to its blob: an allowlist for private, "public" clears it
1805
1847
  void this.shareTransport
1806
1848
  .push(id, [], input.requireAuth ? { githubLogins: allowlist } : "public")
@@ -0,0 +1,208 @@
1
+ import React, { useEffect } from "react";
2
+ import { expect, test } from "bun:test";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { ThreadAgentState } from "@cueloop/schema";
5
+ import { useThreadAgent, type ThreadAgentClient } from "./use-thread-agent";
6
+ import { waitForText, settle } from "../testing/test-support";
7
+
8
+ const initial: ThreadAgentState = {
9
+ threadId: "test",
10
+ phase: { kind: "idle" },
11
+ messages: [],
12
+ tools: [],
13
+ comments: [],
14
+ };
15
+
16
+ test("late configuration and action responses cannot replace a newer accepted state", async () => {
17
+ let state = initial;
18
+ let configure: ((value: ThreadAgentState) => void) | undefined;
19
+ let first: ((value: ThreadAgentState) => void) | undefined;
20
+ let request = async () => false;
21
+ let calls = 0;
22
+ const client: ThreadAgentClient = {
23
+ agentGet: async () => state,
24
+ agentConfigure: async () =>
25
+ new Promise((resolve) => {
26
+ configure = resolve;
27
+ }),
28
+ agentPrompt: async () => {
29
+ if (++calls === 1)
30
+ return new Promise((resolve) => {
31
+ first = resolve;
32
+ });
33
+ state = { ...initial, phase: { kind: "running" } };
34
+
35
+ return state;
36
+ },
37
+ agentCancel: async () => state,
38
+ agentComment: async () => state,
39
+ agentPermission: async () => state,
40
+ };
41
+
42
+ function TestAgent(): React.ReactNode {
43
+ const agent = useThreadAgent("test", undefined, client);
44
+
45
+ useEffect(() => {
46
+ request = () => agent.act((api) => api.agentPrompt({ id: "test", text: "Prompt" }));
47
+ }, [agent]);
48
+
49
+ return <text>{agent.state.phase.kind}</text>;
50
+ }
51
+ const setup = await testRender(<TestAgent />, { width: 40, height: 4 });
52
+
53
+ try {
54
+ await waitForText(setup, "idle");
55
+ const pending = request();
56
+
57
+ await request();
58
+ await waitForText(setup, "running");
59
+ first!(initial);
60
+ await pending;
61
+ configure!(initial);
62
+ await settle(setup);
63
+ expect(setup.captureCharFrame()).toContain("running");
64
+ expect(setup.captureCharFrame()).not.toContain("idle");
65
+ } finally {
66
+ setup.renderer.destroy();
67
+ }
68
+ });
69
+
70
+ test("production subscription shows daemon updates and reconnects without an injected client", async () => {
71
+ const { mkdtempSync, rmSync } = await import("node:fs");
72
+ const { tmpdir } = await import("node:os");
73
+ const { join } = await import("node:path");
74
+ const { DaemonServer } = await import("@cueloop/daemon");
75
+ const { DaemonClient } = await import("@cueloop/daemon/client");
76
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-ui-subscription-"));
77
+ const adapter = {
78
+ id: "ui-subscription",
79
+ label: "UI subscription",
80
+ connect({ onEvent }: Parameters<import("@cueloop/schema").AgentHarnessAdapter["connect"]>[0]) {
81
+ return {
82
+ start: async () => "ui-session",
83
+ prompt: async () => {
84
+ onEvent({ kind: "message" as const, id: "answer", text: "Updated answer" });
85
+
86
+ return { outcome: "completed" as const };
87
+ },
88
+ cancel() {},
89
+ permission() {},
90
+ close() {},
91
+ };
92
+ },
93
+ };
94
+ const options = { home, idleExitMs: 0, threadAgent: { enabled: true, adapter } };
95
+ let server = new DaemonServer(options);
96
+
97
+ server.start();
98
+ const client = await DaemonClient.connect({ home });
99
+ const thread = await client.sessionCreate(
100
+ { repoRoot: home, branch: "main" },
101
+ { type: "plan", content: "Watch this", meta: {} },
102
+ );
103
+
104
+ function TestAgentSubscription(): React.ReactNode {
105
+ const agent = useThreadAgent(thread.id, home);
106
+
107
+ return (
108
+ <text>
109
+ {agent.client ? "Connected " : "Connecting "}
110
+ {agent.state.messages.map((message) => message.text).join(" ")}
111
+ {agent.error}
112
+ </text>
113
+ );
114
+ }
115
+ const setup = await testRender(<TestAgentSubscription />, { width: 80, height: 4 });
116
+
117
+ try {
118
+ await waitForText(setup, "Connected");
119
+ await client.agentPrompt({ id: thread.id, text: "Question" });
120
+ await waitForText(setup, "Updated answer");
121
+ server.stop();
122
+ server = new DaemonServer(options);
123
+ server.start();
124
+ const resumed = await DaemonClient.connect({ home });
125
+
126
+ try {
127
+ await resumed.agentPrompt({ id: thread.id, text: "Continuation" });
128
+ } finally {
129
+ resumed.close();
130
+ }
131
+ await waitForText(setup, "Continuation");
132
+ expect(setup.captureCharFrame()).toContain("Updated answer");
133
+ } finally {
134
+ setup.renderer.destroy();
135
+ client.close();
136
+ server.stop();
137
+ rmSync(home, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("reconnecting during a running turn preserves state without a configuration error", async () => {
142
+ const { mkdtempSync, rmSync } = await import("node:fs");
143
+ const { tmpdir } = await import("node:os");
144
+ const { join } = await import("node:path");
145
+ const { DaemonServer } = await import("@cueloop/daemon");
146
+ const { DaemonClient } = await import("@cueloop/daemon/client");
147
+ const { createTestSdkHarness } = await import("../../../../test/helpers/sdk-harness");
148
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-ui-running-"));
149
+ const harness = createTestSdkHarness();
150
+ const server = new DaemonServer({
151
+ home,
152
+ idleExitMs: 0,
153
+ threadAgent: { enabled: true, adapter: harness.adapter },
154
+ });
155
+
156
+ server.start();
157
+ const client = await DaemonClient.connect({ home });
158
+ const thread = await client.sessionCreate(
159
+ { repoRoot: home, branch: "main" },
160
+ { type: "plan", content: "Watch this", meta: {} },
161
+ );
162
+ let connection: InstanceType<typeof DaemonClient> | undefined;
163
+ let connections = 0;
164
+
165
+ function TestRunningSubscription(): React.ReactNode {
166
+ const agent = useThreadAgent(thread.id, home);
167
+
168
+ useEffect(() => {
169
+ if (agent.client instanceof DaemonClient && agent.client !== connection) {
170
+ connection = agent.client;
171
+ connections++;
172
+ }
173
+ }, [agent.client]);
174
+
175
+ return (
176
+ <text>
177
+ {connections} {agent.state.phase.kind} {agent.error || "No error"}
178
+ </text>
179
+ );
180
+ }
181
+ const setup = await testRender(<TestRunningSubscription />, { width: 100, height: 4 });
182
+
183
+ try {
184
+ await waitForText(setup, "idle");
185
+ await client.agentPrompt({ id: thread.id, text: "Question" });
186
+ await waitForText(setup, "running");
187
+ expect((await client.agentConfigure({ id: thread.id })).phase.kind).toBe("running");
188
+ const rejected = await client
189
+ .agentConfigure({ id: thread.id, configId: "model", value: "other" })
190
+ .catch((error: Error) => error);
191
+
192
+ expect(rejected).toMatchObject({
193
+ code: "internal",
194
+ message: "Thread agent configuration waits for the current turn",
195
+ });
196
+ connection!.close();
197
+ await waitForText(setup, "2 running");
198
+ harness.complete();
199
+ await waitForText(setup, "2 idle");
200
+ expect(setup.captureCharFrame()).toContain("No error");
201
+ expect(setup.captureCharFrame()).not.toContain("configuration waits");
202
+ } finally {
203
+ setup.renderer.destroy();
204
+ client.close();
205
+ server.stop();
206
+ rmSync(home, { recursive: true, force: true });
207
+ }
208
+ });
@@ -0,0 +1,111 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { subscribeThreadState, connectThreadObserver } from "@cueloop/daemon/thread-subscription";
3
+ import { DaemonClient } from "@cueloop/daemon/client";
4
+ import type { ThreadAgentState } from "@cueloop/schema";
5
+
6
+ /** Agent transport is injectable for frame tests; production uses the daemon socket. */
7
+ export type ThreadAgentClient = Pick<
8
+ DaemonClient,
9
+ "agentGet" | "agentPrompt" | "agentCancel" | "agentComment" | "agentPermission"
10
+ > &
11
+ Partial<Pick<DaemonClient, "agentConfigure">>;
12
+
13
+ /** Subscribe to durable agent state; unmount closes only this client connection. */
14
+ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgentClient) {
15
+ const [client, setClient] = useState<ThreadAgentClient | null>(injected ?? null);
16
+ const [state, setState] = useState<ThreadAgentState>({
17
+ threadId: id,
18
+ phase: { kind: "idle" },
19
+ messages: [],
20
+ tools: [],
21
+ comments: [],
22
+ });
23
+ const revision = useRef(0);
24
+ const [error, setError] = useState("");
25
+
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ let connection: DaemonClient | undefined;
29
+ let unsubscribe: (() => void) | undefined;
30
+
31
+ revision.current++;
32
+ const refresh = async (api: ThreadAgentClient) => {
33
+ const requested = ++revision.current;
34
+ const result = await api.agentGet(id);
35
+
36
+ if (!cancelled && requested === revision.current) setState(result);
37
+ };
38
+
39
+ const configure = async (api: ThreadAgentClient): Promise<void> => {
40
+ if (!api.agentConfigure) return;
41
+ const requested = ++revision.current;
42
+ const configured = await api.agentConfigure({ id });
43
+
44
+ if (!cancelled && requested === revision.current) setState(configured);
45
+ await refresh(api);
46
+ };
47
+
48
+ void (async () => {
49
+ try {
50
+ if (injected) {
51
+ await refresh(injected);
52
+ await configure(injected);
53
+
54
+ return;
55
+ }
56
+ unsubscribe = subscribeThreadState({
57
+ connect: connectThreadObserver({ home, autostart: true }),
58
+ matches: (event) => event.event === "agent.updated" && event.sessionId === id,
59
+ read: async (api, signal) => {
60
+ const requested = ++revision.current;
61
+ const state = await api.agentGet(id, { signal });
62
+
63
+ return { state, requested };
64
+ },
65
+ onConnect: (api) => {
66
+ connection = api;
67
+ setClient(api);
68
+ void configure(api).catch((error) => {
69
+ if (!cancelled) setError(String(error));
70
+ });
71
+ },
72
+ onValue: ({ state, requested }) => {
73
+ if (!cancelled && requested === revision.current) setState(state);
74
+ },
75
+ onError: (error) => {
76
+ if (!cancelled) setError(String(error));
77
+ },
78
+ });
79
+ } catch (error) {
80
+ if (!cancelled) setError(error instanceof Error ? error.message : String(error));
81
+ }
82
+ })();
83
+
84
+ return () => {
85
+ cancelled = true;
86
+ unsubscribe?.();
87
+ connection?.close();
88
+ };
89
+ }, [id, home, injected]);
90
+
91
+ const act = async (
92
+ request: (client: ThreadAgentClient) => Promise<ThreadAgentState>,
93
+ ): Promise<boolean> => {
94
+ if (!client) return false;
95
+ try {
96
+ const requested = ++revision.current;
97
+ const result = await request(client);
98
+
99
+ if (requested === revision.current) setState(result);
100
+ setError("");
101
+
102
+ return true;
103
+ } catch (error) {
104
+ setError(error instanceof Error ? error.message : String(error));
105
+
106
+ return false;
107
+ }
108
+ };
109
+
110
+ return { state, client, error, setError, act };
111
+ }
@@ -21,6 +21,7 @@ test("the input is focused on open, so typing lands in the dialog", async () =>
21
21
  />,
22
22
  { width: 60, height: 12 },
23
23
  );
24
+
24
25
  await settle(setup);
25
26
 
26
27
  await typeText(setup, "!");
@@ -45,14 +46,17 @@ test("clicking save fires onSave and clicking cancel fires onCancel", async () =
45
46
  />,
46
47
  { width: 60, height: 12 },
47
48
  );
49
+
48
50
  await settle(setup);
49
51
 
50
52
  const save = locateText(setup, "save");
53
+
51
54
  await setup.mockMouse.click(save.column, save.row);
52
55
  expect(saved).toBe(1);
53
56
  expect(cancelled).toBe(0);
54
57
 
55
58
  const cancel = locateText(setup, "cancel");
59
+
56
60
  await setup.mockMouse.click(cancel.column, cancel.row);
57
61
  expect(cancelled).toBe(1);
58
62
  expect(saved).toBe(1);
@@ -29,6 +29,7 @@ export function RootOverlayProvider({ children }: { children: React.ReactNode })
29
29
  setOverlays((current) => {
30
30
  if (!current.has(key)) return current;
31
31
  const next = new Map(current);
32
+
32
33
  next.delete(key);
33
34
 
34
35
  return next;
@@ -23,6 +23,7 @@ test("clicking the backdrop dismisses; clicking inside the panel does not", asyn
23
23
  </Dialog>,
24
24
  { width: 60, height: 20 },
25
25
  );
26
+
26
27
  await settle(setup);
27
28
 
28
29
  // the top-left corner is backdrop, outside the centered panel
@@ -140,6 +140,7 @@ export function ChangesFileTree({
140
140
  return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
141
141
  if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
142
142
  const row = rows[cursorIndex];
143
+
143
144
  if (!row) return;
144
145
  if (key.name === "return" || key.name === "enter")
145
146
  return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
@@ -41,6 +41,7 @@ test("j moves onto a file and Enter opens it", async () => {
41
41
  />,
42
42
  { width: 40, height: 12 },
43
43
  );
44
+
44
45
  await settle(setup);
45
46
  await waitForText(setup, "a.ts");
46
47
 
@@ -117,6 +118,7 @@ test("without focus the keyboard opens nothing", async () => {
117
118
  <ChangesFileTree files={files} onSelectFile={(path) => opened.push(path)} theme={DARK} />,
118
119
  { width: 40, height: 12 },
119
120
  );
121
+
120
122
  await settle(setup);
121
123
  await waitForText(setup, "a.ts");
122
124
 
@@ -34,12 +34,14 @@ export function FileContentsView({
34
34
  // instance; a state-backed ref rebinds the target once the code renderable exists.
35
35
  const [codeTarget, setCodeTarget] = useState<CodeRenderable | null>(null);
36
36
  const loadRef = useRef(loadContents);
37
+
37
38
  useEffect(() => {
38
39
  loadRef.current = loadContents;
39
40
  });
40
41
 
41
42
  useEffect(() => {
42
43
  let alive = true;
44
+
43
45
  void loadRef.current(path).then(
44
46
  (contents) => {
45
47
  if (alive) setLoaded({ path, lines: contents === null ? null : contents.split("\n") });
@@ -34,6 +34,9 @@ export type DiffSurfaceProps = Pick<
34
34
  | "onAnnotate"
35
35
  | "onReply"
36
36
  | "onUpdateAnnotation"
37
+ | "isAnnotationReadOnly"
38
+ | "annotationAction"
39
+ | "onInvoke"
37
40
  | "resolveAuthorLabel"
38
41
  | "onNavCommand"
39
42
  | "onExit"
@@ -108,6 +111,7 @@ export function GridTabContent(props: {
108
111
  theme: Theme;
109
112
  }): React.ReactNode {
110
113
  const { tab } = props;
114
+
111
115
  if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
112
116
  const filePath = tab.path;
113
117
 
@@ -128,6 +132,9 @@ export function GridTabContent(props: {
128
132
  onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
129
133
  onReply={props.surface.onReply}
130
134
  onUpdateAnnotation={props.surface.onUpdateAnnotation}
135
+ isAnnotationReadOnly={props.surface.isAnnotationReadOnly}
136
+ annotationAction={props.surface.annotationAction}
137
+ onInvoke={props.surface.onInvoke}
131
138
  resolveAuthorLabel={props.surface.resolveAuthorLabel}
132
139
  onExit={props.surface.onExit}
133
140
  theme={props.theme}
@@ -31,6 +31,7 @@ test("Enter expands a folder, then j moves down and Enter opens the file", async
31
31
  />,
32
32
  { width: 40, height: 12 },
33
33
  );
34
+
34
35
  await settle(setup);
35
36
  await waitForText(setup, "src");
36
37
 
@@ -144,6 +145,7 @@ test("without focus the keyboard does nothing", async () => {
144
145
  />,
145
146
  { width: 40, height: 12 },
146
147
  );
148
+
147
149
  await settle(setup);
148
150
  await waitForText(setup, "src");
149
151
 
@@ -223,6 +225,7 @@ test("a double-click requests a persistent file tab", async () => {
223
225
 
224
226
  await waitForText(setup, "README.md");
225
227
  const file = locateText(setup, "README.md");
228
+
226
229
  await setup.mockMouse.doubleClick(file.column, file.row);
227
230
  expect(opened).toEqual([
228
231
  { path: "README.md", persistent: false },
@@ -56,6 +56,7 @@ export function ProjectTreeView({
56
56
  if (key.name === "k" || key.name === "up")
57
57
  return setCursorId(rows[Math.max(cursorIndex - 1, 0)]!.id);
58
58
  const row = rows[cursorIndex];
59
+
59
60
  if (!row) return;
60
61
  if (key.name === "return" || key.name === "enter")
61
62
  return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
@@ -19,10 +19,12 @@ import {
19
19
 
20
20
  function asBranch(node: EditorNode | null): EditorBranch {
21
21
  if (node === null || node.type !== "branch") throw new Error("expected a branch node");
22
+
22
23
  return node;
23
24
  }
24
25
  function asGroup(node: EditorNode | null): EditorGroup {
25
26
  if (node === null || node.type !== "group") throw new Error("expected a group node");
27
+
26
28
  return node;
27
29
  }
28
30
 
@@ -30,6 +32,7 @@ describe("editor grid", () => {
30
32
  test("a fresh group holds its tabs with the first active", () => {
31
33
  const tab = changesTab();
32
34
  const group = makeGroup([tab]);
35
+
33
36
  expect(group.type).toBe("group");
34
37
  expect(group.activeTabId).toBe(tab.id);
35
38
  });
@@ -38,6 +41,7 @@ describe("editor grid", () => {
38
41
  const group = makeGroup([changesTab()]);
39
42
  const file = fileTab("App.tsx", "src/App.tsx", "diff");
40
43
  const next = asGroup(addTab(group, group.id, file));
44
+
41
45
  expect(next.tabs).toHaveLength(2);
42
46
  expect(next.activeTabId).toBe(file.id);
43
47
  });
@@ -81,8 +85,10 @@ describe("editor grid", () => {
81
85
  test("splitting right makes a horizontal branch and focuses the new group", () => {
82
86
  const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
83
87
  const { tree, focusGroupId } = splitGroup(group, group.id, "right");
88
+
84
89
  expect(tree.type).toBe("branch");
85
90
  const branch = asBranch(tree);
91
+
86
92
  expect(branch.orientation).toBe("horizontal");
87
93
  expect(branch.children).toHaveLength(2);
88
94
  // the new group is the second child (right edge) and takes focus
@@ -95,6 +101,7 @@ describe("editor grid", () => {
95
101
  const group = makeGroup([fileTab("a.ts", "a.ts", "contents")]);
96
102
  const { tree, focusGroupId } = splitGroup(group, group.id, "up");
97
103
  const branch = asBranch(tree);
104
+
98
105
  expect(branch.orientation).toBe("vertical");
99
106
  expect(branch.children[0]!.id).toBe(focusGroupId);
100
107
  });
@@ -131,6 +138,7 @@ describe("editor grid", () => {
131
138
  const branch = asBranch(tree);
132
139
  const rightGroup = asGroup(branch.children[1]!);
133
140
  const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
141
+
134
142
  expect(collapsed?.type).toBe("group");
135
143
  expect(asGroup(collapsed).id).toBe(group.id);
136
144
  });
@@ -138,6 +146,7 @@ describe("editor grid", () => {
138
146
  test("closing the only tab of the whole grid returns null", () => {
139
147
  const tab = changesTab();
140
148
  const group = makeGroup([tab]);
149
+
141
150
  expect(closeTab(group, group.id, tab.id)).toBeNull();
142
151
  });
143
152
 
@@ -146,8 +155,10 @@ describe("editor grid", () => {
146
155
  const { tree } = splitGroup(group, group.id, "right");
147
156
  const branch = asBranch(tree);
148
157
  const rightGroup = asGroup(branch.children[1]!);
158
+
149
159
  expect(containsGroup(tree, group.id)).toBe(true);
150
160
  const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
161
+
151
162
  expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
152
163
  });
153
164