@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
@@ -0,0 +1,367 @@
1
+ import React, { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
2
+ import {
3
+ newAnnotationId,
4
+ agentCommentRoot,
5
+ type Thread,
6
+ type ThreadAgentState,
7
+ } from "@cueloop/schema";
8
+ import type { Theme } from "../../appearance/theme";
9
+ import { ThreadView, type ThreadViewProps } from "../../markdown/components/ThreadView";
10
+ import { buildDisplay, marksByDisplay } from "../../markdown/view-plan";
11
+ import { projectThreadConversation, commentOnAgentSpan } from "../agent-transcript";
12
+ import { useThreadAgent, type ThreadAgentClient } from "../use-thread-agent";
13
+ import { agentContinuation } from "../agent-continuation";
14
+ import { AgentConfigControls } from "./AgentConfigControls";
15
+
16
+ /** The opt-in prototype extends the existing Thread body and retains its surrounding chrome. */
17
+ export interface AgentThreadPaneProps {
18
+ thread: Thread;
19
+ home?: string;
20
+ theme: Theme;
21
+ children: React.ReactNode;
22
+ focused: boolean;
23
+ suspended?: boolean;
24
+ onActiveChange: (active: boolean) => void;
25
+ onOpenFile: (path: string) => void;
26
+ onNextPane?: (backward: boolean) => void;
27
+ onControlsChange?: (controls: React.ReactNode) => void;
28
+ onActionsChange?: (
29
+ action: (id: string) => { label: string; run: () => void } | undefined,
30
+ ) => void;
31
+ onRevealReply?: () => void;
32
+ onInvokeChange?: (invoke: (() => void) | undefined) => void;
33
+ onStateChange?: (state: ThreadAgentState) => void;
34
+ flushMutations?: () => Promise<void>;
35
+ client?: ThreadAgentClient;
36
+ }
37
+
38
+ /** Inline input and the final blank line invoke the harness without replacing Send message. */
39
+ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
40
+ const { thread, theme } = props;
41
+ const agent = useThreadAgent(thread.id, props.home, props.client);
42
+ const child =
43
+ React.isValidElement<ThreadViewProps>(props.children) && props.children.type === ThreadView
44
+ ? props.children.props
45
+ : undefined;
46
+ const baseDisplay =
47
+ child?.display ??
48
+ buildDisplay(
49
+ thread.artifact.type === "diff"
50
+ ? (thread.artifact.meta.prBrief ?? "")
51
+ : (thread.workingCopy ?? thread.artifact.content),
52
+ );
53
+ const baseMarks = child?.marks ?? marksByDisplay(thread.annotations, baseDisplay);
54
+ const projection = useMemo(
55
+ () =>
56
+ projectThreadConversation(
57
+ thread,
58
+ agent.state,
59
+ baseDisplay,
60
+ baseMarks,
61
+ agent.state.phase.kind !== "idle" || Boolean(agent.error),
62
+ ),
63
+ [thread, agent.state, agent.error, baseDisplay, baseMarks],
64
+ );
65
+ const [requestedBlock, setRequestedBlock] = useState<{ blockIndex: number }>();
66
+ const draft = useRef("");
67
+ const [promptRestoreRequest, setPromptRestoreRequest] =
68
+ useState<ThreadViewProps["promptRestoreRequest"]>();
69
+ const restoreSequence = useRef(0);
70
+ const invoking = useRef(false);
71
+ const invocations = useRef<{ text: string; writes: Promise<boolean>[] }[]>([]);
72
+ const pendingWrites = useRef<Promise<boolean>[]>([]);
73
+ const state = agent.state;
74
+ const continuation = agentContinuation(state);
75
+ const busy = state.phase.kind === "running" || state.phase.kind === "permission";
76
+
77
+ const [pulse, setPulse] = useState(false);
78
+
79
+ useEffect(() => {
80
+ if (!busy) return;
81
+ const timer = setInterval(() => setPulse((value) => !value), 600);
82
+
83
+ return () => clearInterval(timer);
84
+ }, [busy]);
85
+ const notifyActive = useEffectEvent((active: boolean) => props.onActiveChange(active));
86
+ const notifyState = useEffectEvent(() => props.onStateChange?.(state));
87
+ const notifyControls = useEffectEvent(() =>
88
+ props.onControlsChange?.(
89
+ <AgentConfigControls
90
+ state={state}
91
+ theme={theme}
92
+ onConfigure={(configId, value) =>
93
+ void agent.act((client) => {
94
+ if (!client.agentConfigure)
95
+ return Promise.reject(new Error("Agent configuration is unavailable"));
96
+
97
+ return client.agentConfigure({ id: thread.id, configId, value });
98
+ })
99
+ }
100
+ />,
101
+ ),
102
+ );
103
+
104
+ useEffect(() => {
105
+ notifyActive(true);
106
+
107
+ return () => notifyActive(false);
108
+ }, []);
109
+ useEffect(() => {
110
+ notifyState();
111
+ }, [state]);
112
+ useEffect(() => {
113
+ notifyControls();
114
+ }, [state, theme]);
115
+
116
+ const invoke = async (): Promise<void> => {
117
+ if (thread.status !== "pending") return;
118
+ invocations.current.push({ text: draft.current, writes: pendingWrites.current.splice(0) });
119
+ draft.current = "";
120
+ if (invoking.current) return;
121
+ invoking.current = true;
122
+
123
+ const rejected: string[] = [];
124
+
125
+ try {
126
+ while (invocations.current.length) {
127
+ const input = invocations.current.shift()!;
128
+
129
+ try {
130
+ const writesSaved = (await Promise.all(input.writes)).every(Boolean);
131
+
132
+ if (!writesSaved) {
133
+ rejected.push(input.text);
134
+ continue;
135
+ }
136
+ await props.flushMutations?.();
137
+ const accepted = await agent.act((client) =>
138
+ client.agentPrompt({ id: thread.id, text: input.text }),
139
+ );
140
+
141
+ if (!accepted) rejected.push(input.text);
142
+ } catch (error) {
143
+ rejected.push(input.text);
144
+ agent.setError(String(error));
145
+ }
146
+ }
147
+ } finally {
148
+ invoking.current = false;
149
+ const text = rejected.filter(Boolean).join("\n");
150
+
151
+ if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
152
+ }
153
+ };
154
+ const invokeRef = useRef<() => Promise<void>>(async () => {});
155
+
156
+ useEffect(() => {
157
+ invokeRef.current = invoke;
158
+ });
159
+ const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
160
+ props.onInvokeChange?.(invoke),
161
+ );
162
+
163
+ useEffect(() => {
164
+ notifyInvoke(() => void invokeRef.current());
165
+
166
+ return () => notifyInvoke(undefined);
167
+ }, []);
168
+ const reply = (id: string, body: string): void => {
169
+ const origin = projection.mirrors.get(id)?.commentId ?? id;
170
+ const root = agentCommentRoot(state, origin);
171
+
172
+ if (root)
173
+ pendingWrites.current.push(
174
+ agent.act((client) =>
175
+ client.agentComment({
176
+ id: thread.id,
177
+ comment: {
178
+ ...root,
179
+ id: newAnnotationId(),
180
+ body,
181
+ sent: false,
182
+ author: undefined,
183
+ replyTo: root.replyTo ?? root.id,
184
+ },
185
+ }),
186
+ ),
187
+ );
188
+ else child?.onReply(origin, body);
189
+ };
190
+ const actionFor = (id: string) => {
191
+ const mirror = projection.mirrors.get(id);
192
+ const submission = state.submissions?.find(
193
+ (entry) => entry.id === mirror?.submissionId || entry.commentId === id,
194
+ );
195
+
196
+ if (!submission) return undefined;
197
+ if (submission.status === "failed")
198
+ return {
199
+ label: "Retry",
200
+ run: () =>
201
+ void agent.act((client) =>
202
+ client.agentPrompt({ id: thread.id, text: "", retry: submission.id }),
203
+ ),
204
+ };
205
+
206
+ return {
207
+ label: "View reply",
208
+ run: () => {
209
+ props.onRevealReply?.();
210
+ setRequestedBlock({
211
+ blockIndex: projection.destinations.get(submission.id) ?? projection.tailIndex,
212
+ });
213
+ },
214
+ };
215
+ };
216
+
217
+ const notifyActions = useEffectEvent(() => props.onActionsChange?.(actionFor));
218
+
219
+ useEffect(() => {
220
+ notifyActions();
221
+ }, [state, projection]);
222
+
223
+ return (
224
+ <ThreadView
225
+ {...child}
226
+ session={projection.session}
227
+ display={projection.display}
228
+ marks={projection.marks}
229
+ quickActions={child?.quickActions ?? []}
230
+ observer={false}
231
+ resolved={child?.resolved ?? thread.status !== "pending"}
232
+ suspended={!props.focused || props.suspended}
233
+ theme={theme}
234
+ requestedBlock={requestedBlock}
235
+ isAnnotationReadOnly={(id) =>
236
+ projection.mirrors.has(id) ||
237
+ Boolean(state.submissions?.some((entry) => entry.commentId === id)) ||
238
+ Boolean(state.comments.find((entry) => entry.id === id)?.sent)
239
+ }
240
+ annotationAction={actionFor}
241
+ onInvoke={() => void invoke()}
242
+ isPromptBlock={(index) => index === projection.tailIndex}
243
+ promptRestoreRequest={promptRestoreRequest}
244
+ promptFocusRequest={
245
+ continuation.kind === "ready"
246
+ ? { replyId: continuation.replyId, blockIndex: projection.tailIndex }
247
+ : undefined
248
+ }
249
+ canAnnotateBlock={(index) => {
250
+ const source = projection.sources[index];
251
+
252
+ return (
253
+ source?.kind !== "activity" &&
254
+ (source?.kind !== "message" ||
255
+ Boolean(
256
+ state.messages.find(
257
+ (entry) => entry.id === source.messageId && entry.role === "agent",
258
+ )?.complete,
259
+ ))
260
+ );
261
+ }}
262
+ onAnnotate={(span, body) => {
263
+ const source = projection.sources[span.start.blockIndex];
264
+ const end = projection.sources[span.end.blockIndex];
265
+
266
+ if (source?.kind === "tail") {
267
+ draft.current = body;
268
+
269
+ return;
270
+ }
271
+ if (source?.kind === "mirror") {
272
+ if (source.commentId) reply(source.commentId, body);
273
+
274
+ return;
275
+ }
276
+ if (source?.kind === "artifact" && end?.kind === "artifact") {
277
+ child?.onAnnotate(span, body);
278
+
279
+ return;
280
+ }
281
+ if (
282
+ source?.kind !== "message" ||
283
+ end?.kind !== "message" ||
284
+ source.messageId !== end.messageId
285
+ )
286
+ return;
287
+ const message = state.messages.find((entry) => entry.id === source.messageId)!;
288
+ const localState = { ...state, messages: [message] };
289
+ const comment = commentOnAgentSpan(
290
+ thread,
291
+ localState,
292
+ {
293
+ start: { ...span.start, blockIndex: source.blockIndex + 1 },
294
+ end: { ...span.end, blockIndex: end.blockIndex + 1 },
295
+ },
296
+ body,
297
+ newAnnotationId(),
298
+ );
299
+
300
+ pendingWrites.current.push(
301
+ agent.act((client) => client.agentComment({ id: thread.id, comment })),
302
+ );
303
+ }}
304
+ onReply={reply}
305
+ onUpdateAnnotation={(id, body) => {
306
+ const comment = state.comments.find((entry) => entry.id === id);
307
+
308
+ if (comment)
309
+ pendingWrites.current.push(
310
+ agent.act((client) =>
311
+ client.agentComment({ id: thread.id, comment: { ...comment, body } }),
312
+ ),
313
+ );
314
+ else child?.onUpdateAnnotation(id, body);
315
+ }}
316
+ onExit={child?.onExit ?? (() => {})}
317
+ renderBlock={(index) =>
318
+ index === projection.activityIndex ? (
319
+ <box style={{ flexDirection: "column", paddingLeft: 2 }}>
320
+ {busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
321
+ {state.phase.kind === "permission" ? (
322
+ <>
323
+ <text fg={theme.text}>{state.phase.permission.title}</text>
324
+ <box style={{ flexDirection: "row", gap: 2 }}>
325
+ {state.phase.permission.options.map((option) => (
326
+ <text
327
+ key={option.optionId}
328
+ fg={theme.blue}
329
+ onMouseUp={() => {
330
+ if (state.phase.kind === "permission")
331
+ void agent.act((client) =>
332
+ client.agentPermission({
333
+ id: thread.id,
334
+ requestId:
335
+ state.phase.kind === "permission" ? state.phase.permission.id : "",
336
+ optionId: option.optionId,
337
+ }),
338
+ );
339
+ }}
340
+ >
341
+ {option.name}
342
+ </text>
343
+ ))}
344
+ </box>
345
+ </>
346
+ ) : null}
347
+ {agent.error ? <text fg={theme.red}>{agent.error}</text> : null}
348
+ </box>
349
+ ) : undefined
350
+ }
351
+ />
352
+ );
353
+ }
354
+
355
+ /** The prototype is local and opt-in; shared views keep their existing renderer. */
356
+ export function AgentThreadPrototype(
357
+ props: AgentThreadPaneProps & { enabled: boolean; observer: boolean; pixelPrototype: boolean },
358
+ ): React.ReactNode {
359
+ if (!props.enabled || props.observer || props.pixelPrototype) return props.children;
360
+
361
+ return <AgentThreadPane {...props} />;
362
+ }
363
+
364
+ /** Embedded-agent chords apply only while the Thread owns focus. */
365
+ export function agentOwnsKeyboard(active: boolean, focusedPane: string): boolean {
366
+ return active && focusedPane === "thread";
367
+ }
@@ -21,11 +21,13 @@ test("a long branch truncates on one line and keeps the send control in place",
21
21
  </box>,
22
22
  { width: 60, height: 3 },
23
23
  );
24
+
24
25
  await settle(setup);
25
26
  await settle(setup);
26
27
 
27
28
  const frame = setup.captureCharFrame();
28
29
  const footerRow = frame.split("\n").find((line) => line.includes("cueloop"))!;
30
+
29
31
  // the repo/branch context and the send control share the single footer row
30
32
  expect(footerRow).toContain("Send message (0)");
31
33
  // the branch is clipped to an ellipsis, not wrapped onto a second row
@@ -10,6 +10,7 @@ import { truncateTitle } from "../../ui/components/truncate-title";
10
10
  import { NERD } from "../../ui/components/primitives/icons";
11
11
 
12
12
  export interface ThreadFooterProps {
13
+ controls?: React.ReactNode;
13
14
  repo: string;
14
15
  branch: string;
15
16
  onSubmit?: () => void;
@@ -27,6 +28,7 @@ const SEPARATOR = " / ";
27
28
  export const THREAD_FOOTER_HEIGHT = 2;
28
29
 
29
30
  export function ThreadFooter({
31
+ controls,
30
32
  repo,
31
33
  branch,
32
34
  onSubmit,
@@ -66,6 +68,7 @@ export function ThreadFooter({
66
68
  </text>
67
69
  </box>
68
70
  <box style={{ flexGrow: 1 }} />
71
+ {controls}
69
72
  <box onMouseUp={canSubmit ? onSubmit : undefined} style={{ flexShrink: 0 }}>
70
73
  <text fg={canSubmit ? tokens.accent : tokens.textDim}>
71
74
  {`Send message (${pendingAnnotations})`}
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { ThreadTree } from "./ThreadTree";
5
+ import { TooltipProvider } from "../../ui/components/Tooltip";
6
+ import { locateText, waitForText } from "../../testing/test-support";
7
+
8
+ test("New Thread button sits beside Threads and exposes its hover tooltip", async () => {
9
+ let created = 0;
10
+ const setup = await testRender(
11
+ <TooltipProvider>
12
+ <ThreadTree rows={[]} cursor={0} onCreateThread={() => created++} />
13
+ </TooltipProvider>,
14
+ { width: 30, height: 8 },
15
+ );
16
+
17
+ try {
18
+ await waitForText(setup, "Threads");
19
+ const button = locateText(setup, "+");
20
+ const heading = locateText(setup, "Threads");
21
+
22
+ expect(button.row).toBe(heading.row);
23
+ await setup.mockMouse.moveTo(button.column, button.row);
24
+ await waitForText(setup, "New Thread");
25
+ await setup.mockMouse.click(button.column, button.row);
26
+ expect(created).toBe(1);
27
+ } finally {
28
+ setup.renderer.destroy();
29
+ }
30
+ });
31
+
32
+ test("ordinary sidebar has no New Thread button", async () => {
33
+ const setup = await testRender(<ThreadTree rows={[]} cursor={0} />, { width: 30, height: 8 });
34
+
35
+ try {
36
+ await waitForText(setup, "no threads");
37
+ expect(setup.captureCharFrame()).not.toContain("+");
38
+ } finally {
39
+ setup.renderer.destroy();
40
+ }
41
+ });
@@ -37,6 +37,8 @@ export interface ThreadTreeProps {
37
37
  onPin?: (id: string) => void;
38
38
  /** Rename a thread's title (the menu's Rename). */
39
39
  onRename?: (id: string, title: string) => void;
40
+ /** Create an empty Thread; omitting it retains the ordinary sidebar. */
41
+ onCreateThread?: () => void;
40
42
  theme?: Theme;
41
43
  }
42
44
 
@@ -192,6 +194,7 @@ export function ThreadTree({
192
194
  onRequestDelete,
193
195
  onPin,
194
196
  onRename,
197
+ onCreateThread,
195
198
  theme,
196
199
  }: ThreadTreeProps): React.ReactNode {
197
200
  const tokens = useComponentTheme(theme);
@@ -201,17 +204,34 @@ export function ThreadTree({
201
204
  const titleWidth = Math.max(8, width - 9);
202
205
  const closeMenu = (): void => menuControl.closeMenu();
203
206
 
207
+ const visibleRows =
208
+ onCreateThread && !rows.some((row) => row.id === "section:threads")
209
+ ? [...rows, { kind: "section" as const, id: "section:threads", label: "Threads" }]
210
+ : rows;
211
+
204
212
  return (
205
213
  <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1 }}>
206
- {rows.length === 0 ? (
214
+ {visibleRows.length === 0 ? (
207
215
  <text fg={tokens.textDim}>no threads</text>
208
216
  ) : (
209
- rows.map((row, index) => {
217
+ visibleRows.map((row, index) => {
210
218
  if (row.kind === "section") {
211
219
  return (
212
- <text key={row.id} fg={tokens.textDim} style={{ marginTop: index > 0 ? 1 : 0 }}>
213
- {row.label}
214
- </text>
220
+ <box key={row.id} style={{ flexDirection: "row", marginTop: index > 0 ? 1 : 0 }}>
221
+ <text fg={tokens.textDim}>{row.label}</text>
222
+ {row.id === "section:threads" && onCreateThread ? (
223
+ <>
224
+ <box style={{ flexGrow: 1 }} />
225
+ <IconButton
226
+ glyph="+"
227
+ tip="New Thread"
228
+ onPress={onCreateThread}
229
+ marginRight={1}
230
+ theme={theme}
231
+ />
232
+ </>
233
+ ) : null}
234
+ </box>
215
235
  );
216
236
  }
217
237
 
@@ -4,6 +4,7 @@ import { groupInbox, projectName } from "./thread-tree";
4
4
 
5
5
  function session(id: string, title: string, rootCommit?: string, remote?: string): Thread {
6
6
  const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
7
+
7
8
  if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
8
9
  if (remote !== undefined) workspace.remote = remote;
9
10
 
@@ -61,6 +62,7 @@ describe("groupInbox", () => {
61
62
 
62
63
  // Assert
63
64
  const labels = rows.map((row) => `${row.kind}:${"label" in row ? row.label : row.id}`);
65
+
64
66
  expect(labels).toEqual([
65
67
  "section:Projects",
66
68
  "project:widget",
@@ -83,6 +85,7 @@ describe("groupInbox", () => {
83
85
 
84
86
  // Assert
85
87
  const projectRows = rows.filter((row) => row.kind === "project");
88
+
86
89
  expect(projectRows).toHaveLength(1);
87
90
  expect(ordered).toHaveLength(2);
88
91
  });
@@ -18,6 +18,7 @@ export function projectName(workspace: WorkspaceKey): string {
18
18
  .split(/[/:]/)
19
19
  .filter((part) => part.length > 0)
20
20
  .at(-1);
21
+
21
22
  if (base !== undefined) return base;
22
23
  }
23
24
 
@@ -66,12 +67,14 @@ export function groupInbox(
66
67
  }
67
68
 
68
69
  const key = session.workspace.rootCommit;
70
+
69
71
  if (key === undefined) {
70
72
  standalone.push(session);
71
73
  continue;
72
74
  }
73
75
 
74
76
  const existing = projects.get(key);
77
+
75
78
  if (existing !== undefined) existing.sessions.push(session);
76
79
  else projects.set(key, { name: projectName(session.workspace), sessions: [session] });
77
80
  }
@@ -89,6 +92,7 @@ export function groupInbox(
89
92
  }
90
93
 
91
94
  const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
95
+
92
96
  if (projectEntries.length > 0) {
93
97
  rows.push({ kind: "section", id: "section:projects", label: "Projects" });
94
98
  for (const [key, project] of projectEntries) {
@@ -450,6 +450,7 @@ describe("diff file fold", () => {
450
450
  { path: "src/gone.ts", oldContents: "old line\n", newContents: "", status: "deleted" },
451
451
  ];
452
452
  const session = diffSession(files);
453
+
453
454
  session.artifact.content = patch;
454
455
  session.revisions[0]!.content = patch;
455
456
  const { controller } = await connected(session);
@@ -134,6 +134,7 @@ describe("live working-tree diff for a non-diff thread", () => {
134
134
  openClient: async () => fakeClient(session),
135
135
  shareTransport,
136
136
  });
137
+
137
138
  controller.connect();
138
139
  await tick();
139
140
 
@@ -169,6 +170,7 @@ describe("live working-tree diff for a non-diff thread", () => {
169
170
  openClient: async () => client,
170
171
  shareTransport,
171
172
  });
173
+
172
174
  controller.connect();
173
175
  await tick();
174
176
  await controller.repoChanges();
@@ -213,11 +215,13 @@ describe("live working-tree diff for a non-diff thread", () => {
213
215
  openClient: async () => client,
214
216
  shareTransport,
215
217
  });
218
+
216
219
  controller.connect();
217
220
  await tick();
218
221
 
219
222
  // thread A's diff is still in flight when the user switches to thread B
220
223
  const inFlightA = controller.repoChanges();
224
+
221
225
  controller.open("ses_B");
222
226
  await controller.repoChanges();
223
227
  // B's diff is showing; now A's stale response lands
@@ -272,6 +276,7 @@ describe("frozen vs live diff by thread kind", () => {
272
276
  openClient: async () => fakeClient(session),
273
277
  shareTransport,
274
278
  });
279
+
275
280
  controller.connect();
276
281
  await tick();
277
282
 
@@ -294,6 +299,7 @@ describe("frozen vs live diff by thread kind", () => {
294
299
  openClient: async () => client,
295
300
  shareTransport,
296
301
  });
302
+
297
303
  controller.connect();
298
304
  await tick();
299
305
 
@@ -338,6 +344,7 @@ describe("serve mode pins the served thread to a frozen snapshot", () => {
338
344
  meta: { workbench: true, snapshot: true },
339
345
  },
340
346
  });
347
+
341
348
  controller.connect();
342
349
  await tick();
343
350
 
@@ -303,6 +303,7 @@ describe("reply", () => {
303
303
 
304
304
  // Act
305
305
  const id = controller.reply("a1", "agreed");
306
+
306
307
  await tick();
307
308
 
308
309
  // Assert