@cueloop/client 0.1.0-alpha.98 → 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 (60) hide show
  1. package/package.json +11 -11
  2. package/src/annotations/components/AnnotationCards.tsx +2 -2
  3. package/src/annotations/use-annotation-surface.tsx +13 -2
  4. package/src/app/App.deleted-scroll.test.tsx +1 -0
  5. package/src/app/App.diff.test.tsx +1 -0
  6. package/src/app/App.identity.test.tsx +1 -0
  7. package/src/app/App.regression.test.tsx +5 -0
  8. package/src/app/App.share-dialog.test.tsx +5 -0
  9. package/src/app/App.submit-confirm.test.tsx +3 -0
  10. package/src/app/App.test.tsx +1 -0
  11. package/src/app/App.tsx +6 -0
  12. package/src/app/App.visual.test.tsx +2 -0
  13. package/src/app/App.workbench.test.tsx +53 -0
  14. package/src/app/app-screens.tsx +1 -0
  15. package/src/app/components/AppShell.tsx +1 -0
  16. package/src/app/run.ts +1 -0
  17. package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
  18. package/src/diff/components/AnnotatableFileView.tsx +2 -0
  19. package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
  20. package/src/diff/components/DiffContentView.tsx +1 -0
  21. package/src/diff/diff-intraline.test.ts +1 -0
  22. package/src/diff/diff-intraline.ts +2 -0
  23. package/src/diff/split-diff.ts +2 -0
  24. package/src/integrations/components/ShareDialog.tsx +1 -0
  25. package/src/markdown/components/ThreadView.tsx +2 -0
  26. package/src/markdown/mark-runs.ts +1 -0
  27. package/src/markdown/markdown-highlight.ts +2 -0
  28. package/src/performance/otlp-trace.test.ts +3 -0
  29. package/src/performance/otlp-trace.ts +4 -0
  30. package/src/performance/perf-report.ts +1 -0
  31. package/src/performance/perf-timings.ts +1 -0
  32. package/src/settings/components/quick-actions-editor.test.tsx +3 -0
  33. package/src/stories/stories-app.tsx +3 -0
  34. package/src/stories/story.ts +2 -0
  35. package/src/terminal/pty.ts +1 -0
  36. package/src/thread/agent-transcript.ts +4 -0
  37. package/src/thread/components/AgentConfigControls.test.tsx +2 -0
  38. package/src/thread/components/AgentThreadPane.test.tsx +54 -5
  39. package/src/thread/components/AgentThreadPane.tsx +11 -0
  40. package/src/thread/components/ThreadFooter.test.tsx +2 -0
  41. package/src/thread/components/thread-tree.test.ts +3 -0
  42. package/src/thread/components/thread-tree.ts +4 -0
  43. package/src/thread/thread-controller.curate.test.ts +1 -0
  44. package/src/thread/thread-controller.live-diff.test.ts +7 -0
  45. package/src/thread/thread-controller.share.test.ts +1 -0
  46. package/src/thread/thread-controller.ts +8 -0
  47. package/src/thread/use-thread-agent.test.tsx +143 -0
  48. package/src/thread/use-thread-agent.ts +22 -12
  49. package/src/ui/components/PromptDialog.test.tsx +4 -0
  50. package/src/ui/components/RootOverlay.tsx +1 -0
  51. package/src/ui/components/primitives/Dialog.test.tsx +1 -0
  52. package/src/workbench/components/ChangesColumn.tsx +1 -0
  53. package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
  54. package/src/workbench/components/FileContentsView.tsx +2 -0
  55. package/src/workbench/components/GridTabContent.tsx +1 -0
  56. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  57. package/src/workbench/components/ProjectTreeView.tsx +1 -0
  58. package/src/workbench/components/editor-grid.test.ts +11 -0
  59. package/src/workbench/components/editor-grid.ts +10 -0
  60. package/src/workbench/use-changes-workbench.ts +5 -0
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { subscribeThreadState, connectThreadObserver } from "@cueloop/daemon/thread-subscription";
2
3
  import { DaemonClient } from "@cueloop/daemon/client";
3
4
  import type { ThreadAgentState } from "@cueloop/schema";
4
5
 
@@ -26,6 +27,7 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
26
27
  let cancelled = false;
27
28
  let connection: DaemonClient | undefined;
28
29
  let unsubscribe: (() => void) | undefined;
30
+
29
31
  revision.current++;
30
32
  const refresh = async (api: ThreadAgentClient) => {
31
33
  const requested = ++revision.current;
@@ -51,21 +53,29 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
51
53
 
52
54
  return;
53
55
  }
54
- connection = await DaemonClient.connect({ home, autostart: true });
55
- if (cancelled) {
56
- connection.close();
57
- return;
58
- }
59
- setClient(connection);
60
- unsubscribe = connection.onEvent((event) => {
61
- if (event.event === "agent.updated" && event.sessionId === id)
62
- void refresh(connection!).catch((error) => {
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) => {
63
69
  if (!cancelled) setError(String(error));
64
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
+ },
65
78
  });
66
- await connection.subscribe();
67
- await refresh(connection);
68
- await configure(connection);
69
79
  } catch (error) {
70
80
  if (!cancelled) setError(error instanceof Error ? error.message : String(error));
71
81
  }
@@ -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") });
@@ -111,6 +111,7 @@ export function GridTabContent(props: {
111
111
  theme: Theme;
112
112
  }): React.ReactNode {
113
113
  const { tab } = props;
114
+
114
115
  if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
115
116
  const filePath = tab.path;
116
117
 
@@ -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
 
@@ -81,6 +81,7 @@ export function firstGroupId(node: EditorNode): string {
81
81
  /** Whether a group id still exists in the grid; used to re-home focus after a prune. */
82
82
  export function containsGroup(node: EditorNode, groupId: string): boolean {
83
83
  if (node.type === "group") return node.id === groupId;
84
+
84
85
  return node.children.some((child) => containsGroup(child, groupId));
85
86
  }
86
87
 
@@ -97,6 +98,7 @@ function replaceGroup(
97
98
  fn: (group: EditorGroup) => EditorNode,
98
99
  ): EditorNode {
99
100
  if (node.type === "group") return node.id === id ? fn(node) : node;
101
+
100
102
  return { ...node, children: node.children.map((child) => replaceGroup(child, id, fn)) };
101
103
  }
102
104
 
@@ -223,8 +225,10 @@ export function keepFileTab(node: EditorNode, groupId: string, tabId: string): E
223
225
  export function pruneEmpty(node: EditorNode): EditorNode | null {
224
226
  if (node.type === "group") return node.tabs.length > 0 ? node : null;
225
227
  const kept = node.children.map(pruneEmpty).filter((child): child is EditorNode => child !== null);
228
+
226
229
  if (kept.length === 0) return null;
227
230
  if (kept.length === 1) return kept[0]!;
231
+
228
232
  return { ...node, children: kept };
229
233
  }
230
234
 
@@ -233,8 +237,10 @@ export function closeTab(node: EditorNode, groupId: string, tabId: string): Edit
233
237
  const next = replaceGroup(node, groupId, (group) => {
234
238
  const tabs = group.tabs.filter((tab) => tab.id !== tabId);
235
239
  const activeTabId = group.activeTabId === tabId ? (tabs[0]?.id ?? null) : group.activeTabId;
240
+
236
241
  return { ...group, tabs, activeTabId };
237
242
  });
243
+
238
244
  return pruneEmpty(next);
239
245
  }
240
246
 
@@ -249,10 +255,12 @@ export function splitGroup(
249
255
  let focusGroupId = groupId;
250
256
  const tree = replaceGroup(node, groupId, (group) => {
251
257
  const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
258
+
252
259
  if (active === undefined) return group;
253
260
  const movedTab: EditorTab =
254
261
  active.kind === "welcome" ? { ...active, id: freshId() } : { ...active, preview: false };
255
262
  const newGroup = makeGroup([movedTab]);
263
+
256
264
  focusGroupId = newGroup.id;
257
265
  const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
258
266
  const newFirst = direction === "left" || direction === "up";
@@ -268,7 +276,9 @@ export function splitGroup(
268
276
  activeTabId: sourceTabs[0]!.id,
269
277
  };
270
278
  const children = newFirst ? [newGroup, sourceGroup] : [sourceGroup, newGroup];
279
+
271
280
  return { type: "branch", id: freshId(), orientation, children };
272
281
  });
282
+
273
283
  return { tree, focusGroupId };
274
284
  }
@@ -141,24 +141,29 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
141
141
  const close = (groupId: string, tabId: string): void => {
142
142
  setGrid((tree) => {
143
143
  const pruned = closeTab(tree, groupId, tabId);
144
+
144
145
  if (pruned === null) {
145
146
  setChangesOpen(false);
146
147
  setProjectMode("tree");
147
148
  // leaving zoom on would hide the Thread pane with no editor to fill the gap
148
149
  setZoomed(false);
150
+
149
151
  return makeGroup([changesTab()]);
150
152
  }
151
153
  // re-home focus when the closed group was pruned away, so the next open has a live target
152
154
  setFocusedGroup((current) =>
153
155
  current !== null && containsGroup(pruned, current) ? current : firstGroupId(pruned),
154
156
  );
157
+
155
158
  return pruned;
156
159
  });
157
160
  };
158
161
  const split = (groupId: string, direction: SplitDirection): void => {
159
162
  setGrid((tree) => {
160
163
  const result = splitGroup(tree, groupId, direction);
164
+
161
165
  setFocusedGroup(result.focusGroupId);
166
+
162
167
  return result.tree;
163
168
  });
164
169
  };