@cueloop/client 0.1.0-alpha.101 → 0.1.0-alpha.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/annotations/components/AnnotationCards.tsx +10 -1
- package/src/annotations/use-annotation-surface.tsx +57 -19
- package/src/app/App.tsx +5 -5
- package/src/diff/components/AnnotatableFileView.tsx +2 -2
- package/src/diff/components/DiffContentView.tsx +3 -3
- package/src/markdown/components/ThreadView.tsx +3 -3
- package/src/thread/agent-transcript.ts +11 -10
- package/src/thread/components/AgentThreadPane.test.tsx +226 -1
- package/src/thread/components/AgentThreadPane.tsx +36 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.102",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.102",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.102",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.102",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.102",
|
|
34
34
|
"@opentui/core": "^0.5.14",
|
|
35
35
|
"@opentui/keymap": "0.5.14",
|
|
36
36
|
"@opentui/react": "^0.5.14",
|
|
@@ -317,12 +317,14 @@ export function CommentRow({
|
|
|
317
317
|
tokens,
|
|
318
318
|
authorLabel,
|
|
319
319
|
action,
|
|
320
|
+
onFocus,
|
|
320
321
|
}: {
|
|
321
322
|
annotation: Annotation;
|
|
322
323
|
tokens: Theme;
|
|
323
324
|
/** The author's resolved display name, shown as a tooltip when the dot is hovered. */
|
|
324
325
|
authorLabel?: string;
|
|
325
326
|
action?: { label: string; run: () => void };
|
|
327
|
+
onFocus?: () => void;
|
|
326
328
|
}): React.ReactNode {
|
|
327
329
|
// own comments (no author) wear the filled dot, collaborators the outline
|
|
328
330
|
const own = annotation.author === undefined;
|
|
@@ -337,7 +339,14 @@ export function CommentRow({
|
|
|
337
339
|
: {};
|
|
338
340
|
|
|
339
341
|
return (
|
|
340
|
-
<box
|
|
342
|
+
<box
|
|
343
|
+
style={{ flexDirection: "column" }}
|
|
344
|
+
onMouseDown={(event) => {
|
|
345
|
+
if (!onFocus) return;
|
|
346
|
+
event.stopPropagation();
|
|
347
|
+
onFocus();
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
341
350
|
{annotation.body.split("\n").map((line, lineIndex) => (
|
|
342
351
|
<box key={lineIndex} style={{ flexDirection: "row" }}>
|
|
343
352
|
<box style={{ flexShrink: 0 }} {...(lineIndex === 0 ? dotHover : {})}>
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
slashItemsFrom,
|
|
48
48
|
} from "../keyboard/slash-palette";
|
|
49
49
|
import { SlashSkillsContext } from "../keyboard/skills";
|
|
50
|
-
import { discussionsFrom, type Discussion } from "./discussions";
|
|
50
|
+
import { discussionsFrom, spanKey, type Discussion } from "./discussions";
|
|
51
51
|
import {
|
|
52
52
|
CommentRow,
|
|
53
53
|
Composer,
|
|
@@ -107,13 +107,13 @@ export interface AnnotationSurfaceOptions {
|
|
|
107
107
|
) => boolean;
|
|
108
108
|
focusedAnnotationId?: string;
|
|
109
109
|
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
110
|
-
onAnnotate: (span: TextSpan, body: string) => void;
|
|
111
|
-
onReply: (rootAnnotationId: string, body: string) => void;
|
|
110
|
+
onAnnotate: (span: TextSpan, body: string) => string | void;
|
|
111
|
+
onReply: (rootAnnotationId: string, body: string) => string | void;
|
|
112
112
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
113
113
|
isAnnotationReadOnly?: (id: string) => boolean;
|
|
114
114
|
annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
|
|
115
115
|
requestedBlock?: { blockIndex: number };
|
|
116
|
-
onInvoke?: () => void;
|
|
116
|
+
onInvoke?: (commentId?: string) => void;
|
|
117
117
|
isPromptBlock?: (blockIndex: number) => boolean;
|
|
118
118
|
promptFocusRequest?: PromptFocusRequest;
|
|
119
119
|
promptRestoreRequest?: PromptRestoreRequest;
|
|
@@ -277,17 +277,25 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
277
277
|
// app owns it (onFocusAnnotation), the focused discussion is the one holding
|
|
278
278
|
// the focused card and focusing here names the root comment; standalone, it
|
|
279
279
|
// is local state
|
|
280
|
-
const [localFocus, setLocalFocus] = useState<string | null>(null);
|
|
281
|
-
const
|
|
282
|
-
onFocusAnnotation === undefined
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
)
|
|
280
|
+
const [localFocus, setLocalFocus] = useState<{ key: string; annotationId?: string } | null>(null);
|
|
281
|
+
const getFocusedDiscussion = (): string | null => {
|
|
282
|
+
if (onFocusAnnotation === undefined) return localFocus?.key ?? null;
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
discussions.find((discussion) =>
|
|
286
|
+
discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
|
|
287
|
+
)?.key ?? null
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
const focusedDiscussion = getFocusedDiscussion();
|
|
287
291
|
const setFocusedDiscussion = (key: string | null): void => {
|
|
288
|
-
if (onFocusAnnotation === undefined) return setLocalFocus(key);
|
|
292
|
+
if (onFocusAnnotation === undefined) return setLocalFocus(key === null ? null : { key });
|
|
289
293
|
onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
|
|
290
294
|
};
|
|
295
|
+
const focusSavedComment = (annotationId: string, key: string): void => {
|
|
296
|
+
if (onFocusAnnotation) onFocusAnnotation(annotationId);
|
|
297
|
+
else setLocalFocus({ key, annotationId });
|
|
298
|
+
};
|
|
291
299
|
const [folded, setFolded] = useState<Set<string>>(new Set());
|
|
292
300
|
const [composeText, setComposeText] = useState("");
|
|
293
301
|
const [composerFocusRequest, setComposerFocusRequest] = useState(0);
|
|
@@ -473,7 +481,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
473
481
|
if (!target || body.trim().length === 0) return;
|
|
474
482
|
if (target.editAnnotationId !== null) {
|
|
475
483
|
onUpdateAnnotation(target.editAnnotationId, body);
|
|
476
|
-
if (
|
|
484
|
+
if (target.discussionKey) focusSavedComment(target.editAnnotationId, target.discussionKey);
|
|
485
|
+
if (invoke) onInvoke?.(target.editAnnotationId);
|
|
477
486
|
|
|
478
487
|
return;
|
|
479
488
|
}
|
|
@@ -481,8 +490,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
481
490
|
const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
|
|
482
491
|
|
|
483
492
|
if (discussion) {
|
|
484
|
-
onReply(discussion.rootId, body);
|
|
485
|
-
|
|
493
|
+
const commentId = onReply(discussion.rootId, body);
|
|
494
|
+
|
|
495
|
+
if (commentId) focusSavedComment(commentId, discussion.key);
|
|
496
|
+
if (invoke && commentId) onInvoke?.(commentId);
|
|
486
497
|
|
|
487
498
|
return;
|
|
488
499
|
}
|
|
@@ -490,10 +501,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
490
501
|
const span = target.span ?? caretSpan();
|
|
491
502
|
|
|
492
503
|
if (span) {
|
|
493
|
-
onAnnotate(span, body);
|
|
494
|
-
|
|
504
|
+
const commentId = onAnnotate(span, body);
|
|
505
|
+
|
|
506
|
+
completeSavedComment(span, commentId, prompt, invoke);
|
|
495
507
|
}
|
|
496
508
|
};
|
|
509
|
+
const completeSavedComment = (
|
|
510
|
+
span: TextSpan,
|
|
511
|
+
commentId: string | void,
|
|
512
|
+
prompt: boolean,
|
|
513
|
+
invoke: boolean,
|
|
514
|
+
): void => {
|
|
515
|
+
if (!prompt && commentId) {
|
|
516
|
+
focusSavedComment(commentId, spanKey(span));
|
|
517
|
+
}
|
|
518
|
+
if (invoke && (prompt || commentId)) onInvoke?.(commentId || undefined);
|
|
519
|
+
};
|
|
497
520
|
/** A new discussion on the typing anchor; the card renders under the span's last block. */
|
|
498
521
|
const openNewCompose = (seed: string): void => {
|
|
499
522
|
const span = caretSpan();
|
|
@@ -854,6 +877,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
854
877
|
openNewCompose(sequence);
|
|
855
878
|
};
|
|
856
879
|
|
|
880
|
+
const invokeFocusedComment = (): void => {
|
|
881
|
+
const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
|
|
882
|
+
const focusedCommentId = focusedAnnotationId ?? localFocus?.annotationId;
|
|
883
|
+
const commentId =
|
|
884
|
+
discussion?.annotations.find((annotation) => annotation.id === focusedCommentId)?.id ??
|
|
885
|
+
discussion?.annotations.at(-1)?.id;
|
|
886
|
+
|
|
887
|
+
onInvoke?.(commentId);
|
|
888
|
+
};
|
|
889
|
+
|
|
857
890
|
useSharedKeyboard((key) => {
|
|
858
891
|
if (suspended) return;
|
|
859
892
|
if (key.ctrl && key.name === "q") return onExit();
|
|
@@ -863,7 +896,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
863
896
|
if (isAgentInvokeKey(key, onInvoke)) {
|
|
864
897
|
key.preventDefault();
|
|
865
898
|
|
|
866
|
-
return
|
|
899
|
+
return invokeFocusedComment();
|
|
867
900
|
}
|
|
868
901
|
if (key.name === "escape") {
|
|
869
902
|
// from type mode esc only enters nav, so a held mark survives for `c`
|
|
@@ -951,6 +984,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
951
984
|
tokens={tokens}
|
|
952
985
|
authorLabel={resolveAuthorLabel?.(annotation)}
|
|
953
986
|
action={annotationAction?.(annotation.id)}
|
|
987
|
+
onFocus={() => {
|
|
988
|
+
blurSaveCompose();
|
|
989
|
+
focusSavedComment(annotation.id, discussion.key);
|
|
990
|
+
setCursor(discussion.blockIndex);
|
|
991
|
+
}}
|
|
954
992
|
/>
|
|
955
993
|
),
|
|
956
994
|
};
|
|
@@ -1231,7 +1269,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
1231
1269
|
|
|
1232
1270
|
function isAgentInvokeKey(
|
|
1233
1271
|
key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
|
|
1234
|
-
onInvoke?: () => void,
|
|
1272
|
+
onInvoke?: (commentId?: string) => void,
|
|
1235
1273
|
): boolean {
|
|
1236
1274
|
return Boolean(
|
|
1237
1275
|
onInvoke &&
|
package/src/app/App.tsx
CHANGED
|
@@ -842,7 +842,7 @@ export function App({
|
|
|
842
842
|
const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
|
|
843
843
|
() => undefined,
|
|
844
844
|
);
|
|
845
|
-
const agentInvoke = useRef<(() => void) | undefined>(undefined);
|
|
845
|
+
const agentInvoke = useRef<((commentId?: string) => void) | undefined>(undefined);
|
|
846
846
|
const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
|
|
847
847
|
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
848
848
|
const [welcomeComposing, setWelcomeComposing] = useState(false);
|
|
@@ -1484,7 +1484,7 @@ export function App({
|
|
|
1484
1484
|
// a diff thread's Changes view is the artifact itself; any other thread's is the
|
|
1485
1485
|
// live working-tree diff, so its notes carry a file target and anchor there
|
|
1486
1486
|
onAnnotate: (span, body) =>
|
|
1487
|
-
|
|
1487
|
+
controller.annotate(
|
|
1488
1488
|
"comment",
|
|
1489
1489
|
span.start.blockIndex,
|
|
1490
1490
|
span.start.char,
|
|
@@ -1496,11 +1496,11 @@ export function App({
|
|
|
1496
1496
|
: { kind: "file", path: "", rev: "worktree" },
|
|
1497
1497
|
),
|
|
1498
1498
|
onReply: (rootAnnotationId, body) =>
|
|
1499
|
-
|
|
1499
|
+
controller.reply(rootAnnotationId, body),
|
|
1500
1500
|
onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
|
|
1501
1501
|
onInvoke: agentActive
|
|
1502
|
-
? () => {
|
|
1503
|
-
agentInvoke.current?.();
|
|
1502
|
+
? (commentId) => {
|
|
1503
|
+
agentInvoke.current?.(commentId);
|
|
1504
1504
|
setFocusedPane("thread");
|
|
1505
1505
|
}
|
|
1506
1506
|
: undefined,
|
|
@@ -31,9 +31,9 @@ export interface AnnotatableFileViewProps {
|
|
|
31
31
|
onCursorChange?: (rowIndex: number) => void;
|
|
32
32
|
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
33
33
|
onAddComment: (anchor: Anchor, body: string) => void;
|
|
34
|
-
onReply: (rootAnnotationId: string, body: string) => void;
|
|
34
|
+
onReply: (rootAnnotationId: string, body: string) => string | void;
|
|
35
35
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
36
|
-
onInvoke?: () => void;
|
|
36
|
+
onInvoke?: (commentId?: string) => void;
|
|
37
37
|
annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
|
|
38
38
|
isAnnotationReadOnly?: (id: string) => boolean;
|
|
39
39
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
@@ -86,10 +86,10 @@ export interface DiffContentViewProps {
|
|
|
86
86
|
onCursorChange?: (rowIndex: number) => void;
|
|
87
87
|
focusedAnnotationId?: string;
|
|
88
88
|
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
89
|
-
onAnnotate: (span: TextSpan, body: string) => void;
|
|
90
|
-
onReply: (rootAnnotationId: string, body: string) => void;
|
|
89
|
+
onAnnotate: (span: TextSpan, body: string) => string | void;
|
|
90
|
+
onReply: (rootAnnotationId: string, body: string) => string | void;
|
|
91
91
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
92
|
-
onInvoke?: () => void;
|
|
92
|
+
onInvoke?: (commentId?: string) => void;
|
|
93
93
|
annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
|
|
94
94
|
isAnnotationReadOnly?: (id: string) => boolean;
|
|
95
95
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
@@ -159,7 +159,7 @@ export interface ThreadViewProps {
|
|
|
159
159
|
isAnnotationReadOnly?: (id: string) => boolean;
|
|
160
160
|
annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
|
|
161
161
|
requestedBlock?: { blockIndex: number };
|
|
162
|
-
onInvoke?: () => void;
|
|
162
|
+
onInvoke?: (commentId?: string) => void;
|
|
163
163
|
isPromptBlock?: (blockIndex: number) => boolean;
|
|
164
164
|
promptFocusRequest?: PromptFocusRequest;
|
|
165
165
|
promptRestoreRequest?: PromptRestoreRequest;
|
|
@@ -177,8 +177,8 @@ export interface ThreadViewProps {
|
|
|
177
177
|
onCursorChange?: (blockIndex: number) => void;
|
|
178
178
|
focusedAnnotationId?: string;
|
|
179
179
|
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
180
|
-
onAnnotate: (span: TextSpan, body: string) => void;
|
|
181
|
-
onReply: (rootAnnotationId: string, body: string) => void;
|
|
180
|
+
onAnnotate: (span: TextSpan, body: string) => string | void;
|
|
181
|
+
onReply: (rootAnnotationId: string, body: string) => string | void;
|
|
182
182
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
183
183
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
184
184
|
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
@@ -195,6 +195,8 @@ export function projectThreadConversation(
|
|
|
195
195
|
display.push(...blocks);
|
|
196
196
|
sources.push(...blocks.map(() => ({ kind: "mirror" as const, commentId })));
|
|
197
197
|
annotations.push(annotation);
|
|
198
|
+
const mirrorAnnotations = [annotation];
|
|
199
|
+
|
|
198
200
|
if (commentId) {
|
|
199
201
|
const replies = [...thread.annotations, ...state.comments].filter(
|
|
200
202
|
(entry) => entry.replyTo === commentId,
|
|
@@ -210,23 +212,22 @@ export function projectThreadConversation(
|
|
|
210
212
|
};
|
|
211
213
|
|
|
212
214
|
annotations.push(replica);
|
|
215
|
+
mirrorAnnotations.push(replica);
|
|
213
216
|
mirrors.set(replica.id, { commentId: reply.id });
|
|
214
217
|
}
|
|
215
218
|
}
|
|
216
219
|
const localMarks = quote.trim()
|
|
217
|
-
? marksByDisplay(
|
|
220
|
+
? marksByDisplay(mirrorAnnotations, blocks)
|
|
218
221
|
: new Map<number, Mark[]>([
|
|
219
222
|
[
|
|
220
223
|
0,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
},
|
|
229
|
-
],
|
|
224
|
+
mirrorAnnotations.map((entry) => ({
|
|
225
|
+
start: 0,
|
|
226
|
+
end: 0,
|
|
227
|
+
role: "mark-comment",
|
|
228
|
+
annotationId: entry.id,
|
|
229
|
+
span: { start: { blockIndex: 0, char: 0 }, end: { blockIndex: 0, char: 0 } },
|
|
230
|
+
})),
|
|
230
231
|
],
|
|
231
232
|
]);
|
|
232
233
|
|
|
@@ -374,6 +374,227 @@ test("typing on an accepted plain prompt cannot edit it or create an unmarked co
|
|
|
374
374
|
}
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
+
for (const editingReply of [false, true]) {
|
|
378
|
+
test(`Option+Enter preserves the exact ${editingReply ? "edited" : "new"} reply for Ctrl+Enter`, async () => {
|
|
379
|
+
const submitted: string[] = [];
|
|
380
|
+
const root = {
|
|
381
|
+
id: "root",
|
|
382
|
+
kind: "comment" as const,
|
|
383
|
+
body: "Submitted question",
|
|
384
|
+
createdAt: "2026-10-04",
|
|
385
|
+
anchor: { quote: "Original", prefix: "", suffix: " artifact" },
|
|
386
|
+
};
|
|
387
|
+
const display = buildDisplay(thread.artifact.content);
|
|
388
|
+
|
|
389
|
+
function ControlledDiscussion(): React.ReactNode {
|
|
390
|
+
const [value, setValue] = React.useState<Thread>({
|
|
391
|
+
...thread,
|
|
392
|
+
annotations: editingReply
|
|
393
|
+
? [root, { ...root, id: "reply", replyTo: "root", body: "Editable followup" }]
|
|
394
|
+
: [root],
|
|
395
|
+
});
|
|
396
|
+
const [focused, setFocused] = React.useState<string>();
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<ThreadView
|
|
400
|
+
session={value}
|
|
401
|
+
display={display}
|
|
402
|
+
marks={marksByDisplay(value.annotations, display)}
|
|
403
|
+
quickActions={[]}
|
|
404
|
+
observer={false}
|
|
405
|
+
focusedAnnotationId={focused}
|
|
406
|
+
onFocusAnnotation={setFocused}
|
|
407
|
+
isAnnotationReadOnly={(id) => id === "root"}
|
|
408
|
+
onAnnotate={noop}
|
|
409
|
+
onReply={(id, body) => {
|
|
410
|
+
setValue((current) => ({
|
|
411
|
+
...current,
|
|
412
|
+
annotations: [...current.annotations, { ...root, id: "reply", replyTo: id, body }],
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
return "reply";
|
|
416
|
+
}}
|
|
417
|
+
onUpdateAnnotation={(id, body) =>
|
|
418
|
+
setValue((current) => ({
|
|
419
|
+
...current,
|
|
420
|
+
annotations: current.annotations.map((note) =>
|
|
421
|
+
note.id === id ? { ...note, body } : note,
|
|
422
|
+
),
|
|
423
|
+
}))
|
|
424
|
+
}
|
|
425
|
+
onInvoke={(id) => {
|
|
426
|
+
if (id) submitted.push(id);
|
|
427
|
+
}}
|
|
428
|
+
onExit={noop}
|
|
429
|
+
/>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const setup = await testRender(<ControlledDiscussion />, {
|
|
433
|
+
width: 100,
|
|
434
|
+
height: 24,
|
|
435
|
+
kittyKeyboard: true,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
await waitForText(setup, editingReply ? "Editable followup" : "Submitted question");
|
|
440
|
+
const comment = locateText(setup, editingReply ? "Editable followup" : "Submitted question");
|
|
441
|
+
|
|
442
|
+
await setup.mockMouse.click(comment.column, comment.row);
|
|
443
|
+
await typeText(setup, " Why?");
|
|
444
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
445
|
+
expect(submitted).toEqual([]);
|
|
446
|
+
const saved = locateText(setup, editingReply ? "Editable followup Why?" : "Why?");
|
|
447
|
+
|
|
448
|
+
await setup.mockMouse.click(saved.column, saved.row);
|
|
449
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
450
|
+
expect(submitted).toEqual(["reply"]);
|
|
451
|
+
} finally {
|
|
452
|
+
setup.renderer.destroy();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
test("Option-saved Changes mirror replies send their stored ID with Ctrl+Enter", async () => {
|
|
458
|
+
const root = {
|
|
459
|
+
id: "changes-note",
|
|
460
|
+
kind: "comment" as const,
|
|
461
|
+
body: "Question on Changes",
|
|
462
|
+
createdAt: thread.createdAt,
|
|
463
|
+
anchor: { quote: "Changed code", prefix: "", suffix: "" },
|
|
464
|
+
};
|
|
465
|
+
const { client, prompts } = createTestAgentClient({
|
|
466
|
+
...empty,
|
|
467
|
+
submissions: [
|
|
468
|
+
{
|
|
469
|
+
id: "accepted",
|
|
470
|
+
commentId: root.id,
|
|
471
|
+
prompt: root.body,
|
|
472
|
+
quote: root.anchor.quote,
|
|
473
|
+
status: "completed",
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
function ChangesDiscussion(): React.ReactNode {
|
|
479
|
+
const [value, setValue] = React.useState<Thread>({ ...thread, annotations: [root] });
|
|
480
|
+
const [focused, setFocused] = React.useState<string>();
|
|
481
|
+
const display = buildDisplay(value.artifact.content);
|
|
482
|
+
const saveReply = (id: string, body: string): string => {
|
|
483
|
+
setValue((current) => ({
|
|
484
|
+
...current,
|
|
485
|
+
annotations: [...current.annotations, { ...root, id: "followup", replyTo: id, body }],
|
|
486
|
+
}));
|
|
487
|
+
|
|
488
|
+
return "followup";
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<AgentThreadPane
|
|
493
|
+
thread={value}
|
|
494
|
+
client={client}
|
|
495
|
+
focused
|
|
496
|
+
theme={DARK}
|
|
497
|
+
onActiveChange={noop}
|
|
498
|
+
onOpenFile={noop}
|
|
499
|
+
>
|
|
500
|
+
<ThreadView
|
|
501
|
+
session={value}
|
|
502
|
+
display={display}
|
|
503
|
+
marks={marksByDisplay(value.annotations, display)}
|
|
504
|
+
quickActions={[]}
|
|
505
|
+
observer={false}
|
|
506
|
+
focusedAnnotationId={focused}
|
|
507
|
+
onFocusAnnotation={setFocused}
|
|
508
|
+
onAnnotate={noop}
|
|
509
|
+
onReply={saveReply}
|
|
510
|
+
onUpdateAnnotation={noop}
|
|
511
|
+
onExit={noop}
|
|
512
|
+
/>
|
|
513
|
+
</AgentThreadPane>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const setup = await testRender(<ChangesDiscussion />, {
|
|
517
|
+
width: 100,
|
|
518
|
+
height: 24,
|
|
519
|
+
kittyKeyboard: true,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
await waitForText(setup, root.body);
|
|
524
|
+
const mirror = locateText(setup, root.body);
|
|
525
|
+
|
|
526
|
+
await setup.mockMouse.click(mirror.column, mirror.row);
|
|
527
|
+
await typeText(setup, "Follow up on this change");
|
|
528
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
529
|
+
await waitForText(setup, "Follow up on this change");
|
|
530
|
+
expect(prompts).toHaveLength(0);
|
|
531
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
532
|
+
expect(prompts.map((prompt) => prompt.commentId)).toEqual(["followup"]);
|
|
533
|
+
} finally {
|
|
534
|
+
setup.renderer.destroy();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("Ctrl+Enter submits only the comment just saved, leaving older notes editable", async () => {
|
|
539
|
+
const value: Thread = {
|
|
540
|
+
...thread,
|
|
541
|
+
annotations: [
|
|
542
|
+
{
|
|
543
|
+
id: "older",
|
|
544
|
+
kind: "comment",
|
|
545
|
+
body: "Older unsent note",
|
|
546
|
+
createdAt: "2026-10-04",
|
|
547
|
+
anchor: { quote: "Original", prefix: "", suffix: " artifact" },
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
552
|
+
const display = buildDisplay(value.artifact.content);
|
|
553
|
+
const setup = await testRender(
|
|
554
|
+
<AgentThreadPane
|
|
555
|
+
thread={value}
|
|
556
|
+
theme={DARK}
|
|
557
|
+
focused
|
|
558
|
+
onActiveChange={noop}
|
|
559
|
+
onOpenFile={noop}
|
|
560
|
+
client={client}
|
|
561
|
+
>
|
|
562
|
+
<ThreadView
|
|
563
|
+
session={value}
|
|
564
|
+
display={display}
|
|
565
|
+
marks={marksByDisplay(value.annotations, display)}
|
|
566
|
+
quickActions={[]}
|
|
567
|
+
observer={false}
|
|
568
|
+
onAnnotate={() => "selected"}
|
|
569
|
+
onReply={noop}
|
|
570
|
+
onUpdateAnnotation={noop}
|
|
571
|
+
onExit={noop}
|
|
572
|
+
/>
|
|
573
|
+
</AgentThreadPane>,
|
|
574
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
await waitForText(setup, "Original artifact");
|
|
579
|
+
const artifact = locateText(setup, "Original artifact");
|
|
580
|
+
|
|
581
|
+
await setup.mockMouse.drag(
|
|
582
|
+
artifact.column + 9,
|
|
583
|
+
artifact.row,
|
|
584
|
+
artifact.column + 17,
|
|
585
|
+
artifact.row,
|
|
586
|
+
);
|
|
587
|
+
await typeText(setup, "Only this question");
|
|
588
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
589
|
+
await waitForState(setup, () => prompts.length === 1, "selected comment invocation");
|
|
590
|
+
expect(prompts[0]?.commentId).toBe("selected");
|
|
591
|
+
expect(prompts[0]?.id).toBe(value.id);
|
|
592
|
+
expect(value.annotations[0]?.body).toBe("Older unsent note");
|
|
593
|
+
} finally {
|
|
594
|
+
setup.renderer.destroy();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
377
598
|
test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Enter invokes", async () => {
|
|
378
599
|
const saved: string[] = [];
|
|
379
600
|
let invocations = 0;
|
|
@@ -385,7 +606,11 @@ test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Ent
|
|
|
385
606
|
marks={marksByDisplay([], display)}
|
|
386
607
|
quickActions={[]}
|
|
387
608
|
observer={false}
|
|
388
|
-
onAnnotate={(_span, body) =>
|
|
609
|
+
onAnnotate={(_span, body) => {
|
|
610
|
+
saved.push(body);
|
|
611
|
+
|
|
612
|
+
return `saved-${saved.length}`;
|
|
613
|
+
}}
|
|
389
614
|
onReply={noop}
|
|
390
615
|
onUpdateAnnotation={noop}
|
|
391
616
|
onExit={noop}
|
|
@@ -29,7 +29,7 @@ export interface AgentThreadPaneProps {
|
|
|
29
29
|
action: (id: string) => { label: string; run: () => void } | undefined,
|
|
30
30
|
) => void;
|
|
31
31
|
onRevealReply?: () => void;
|
|
32
|
-
onInvokeChange?: (invoke: (() => void) | undefined) => void;
|
|
32
|
+
onInvokeChange?: (invoke: ((commentId?: string) => void) | undefined) => void;
|
|
33
33
|
onStateChange?: (state: ThreadAgentState) => void;
|
|
34
34
|
flushMutations?: () => Promise<void>;
|
|
35
35
|
client?: ThreadAgentClient;
|
|
@@ -68,7 +68,9 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
68
68
|
useState<ThreadViewProps["promptRestoreRequest"]>();
|
|
69
69
|
const restoreSequence = useRef(0);
|
|
70
70
|
const invoking = useRef(false);
|
|
71
|
-
const invocations = useRef<{ text: string; writes: Promise<boolean>[] }[]>(
|
|
71
|
+
const invocations = useRef<{ text: string; commentId?: string; writes: Promise<boolean>[] }[]>(
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
72
74
|
const pendingWrites = useRef<Promise<boolean>[]>([]);
|
|
73
75
|
const state = agent.state;
|
|
74
76
|
const continuation = agentContinuation(state);
|
|
@@ -115,9 +117,16 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
115
117
|
notifyControls();
|
|
116
118
|
}, [state, theme]);
|
|
117
119
|
|
|
118
|
-
const invoke = async (): Promise<void> => {
|
|
120
|
+
const invoke = async (commentId?: string): Promise<void> => {
|
|
119
121
|
if (thread.status !== "pending") return;
|
|
120
|
-
|
|
122
|
+
if (!commentId && !draft.current.trim()) return;
|
|
123
|
+
invocations.current.push({
|
|
124
|
+
text: draft.current,
|
|
125
|
+
commentId: commentId
|
|
126
|
+
? (projection.mirrors.get(commentId)?.commentId ?? commentId)
|
|
127
|
+
: undefined,
|
|
128
|
+
writes: pendingWrites.current.splice(0),
|
|
129
|
+
});
|
|
121
130
|
draft.current = "";
|
|
122
131
|
if (invoking.current) return;
|
|
123
132
|
invoking.current = true;
|
|
@@ -140,6 +149,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
140
149
|
client.agentPrompt({
|
|
141
150
|
id: thread.id,
|
|
142
151
|
text: input.text,
|
|
152
|
+
commentId: input.commentId,
|
|
143
153
|
inputOnly: Boolean(input.text.trim()),
|
|
144
154
|
operationId: newAnnotationId(),
|
|
145
155
|
}),
|
|
@@ -158,32 +168,36 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
158
168
|
if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
|
|
159
169
|
}
|
|
160
170
|
};
|
|
161
|
-
const invokeRef = useRef<() => Promise<void>>(async () => {});
|
|
171
|
+
const invokeRef = useRef<(commentId?: string) => Promise<void>>(async () => {});
|
|
162
172
|
|
|
163
173
|
useEffect(() => {
|
|
164
174
|
invokeRef.current = invoke;
|
|
165
175
|
});
|
|
166
|
-
const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
|
|
176
|
+
const notifyInvoke = useEffectEvent((invoke: ((commentId?: string) => void) | undefined) =>
|
|
167
177
|
props.onInvokeChange?.(invoke),
|
|
168
178
|
);
|
|
169
179
|
|
|
170
180
|
useEffect(() => {
|
|
171
|
-
notifyInvoke(() => void invokeRef.current());
|
|
181
|
+
notifyInvoke((commentId) => void invokeRef.current(commentId));
|
|
172
182
|
|
|
173
183
|
return () => notifyInvoke(undefined);
|
|
174
184
|
}, []);
|
|
175
|
-
const reply = (id: string, body: string): void => {
|
|
185
|
+
const reply = (id: string, body: string): string | void => {
|
|
176
186
|
const origin = projection.mirrors.get(id)?.commentId ?? id;
|
|
177
187
|
const root = agentCommentRoot(state, origin);
|
|
188
|
+
const replyFocusId = (commentId: string | void): string | void =>
|
|
189
|
+
commentId && projection.mirrors.has(id) ? `${id}:${commentId}` : commentId;
|
|
190
|
+
|
|
191
|
+
if (root) {
|
|
192
|
+
const commentId = newAnnotationId();
|
|
178
193
|
|
|
179
|
-
if (root)
|
|
180
194
|
pendingWrites.current.push(
|
|
181
195
|
agent.act((client) =>
|
|
182
196
|
client.agentComment({
|
|
183
197
|
id: thread.id,
|
|
184
198
|
comment: {
|
|
185
199
|
...root,
|
|
186
|
-
id:
|
|
200
|
+
id: commentId,
|
|
187
201
|
body,
|
|
188
202
|
sent: false,
|
|
189
203
|
author: undefined,
|
|
@@ -192,7 +206,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
192
206
|
}),
|
|
193
207
|
),
|
|
194
208
|
);
|
|
195
|
-
|
|
209
|
+
|
|
210
|
+
return replyFocusId(commentId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return replyFocusId(child?.onReply(origin, body));
|
|
196
214
|
};
|
|
197
215
|
const actionFor = (id: string) => {
|
|
198
216
|
const mirror = projection.mirrors.get(id);
|
|
@@ -245,7 +263,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
245
263
|
Boolean(state.comments.find((entry) => entry.id === id)?.sent)
|
|
246
264
|
}
|
|
247
265
|
annotationAction={actionFor}
|
|
248
|
-
onInvoke={() => void invoke()}
|
|
266
|
+
onInvoke={(commentId) => void invoke(commentId)}
|
|
249
267
|
isPromptBlock={(index) => index === projection.tailIndex}
|
|
250
268
|
promptRestoreRequest={promptRestoreRequest}
|
|
251
269
|
promptFocusRequest={
|
|
@@ -276,14 +294,14 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
276
294
|
return;
|
|
277
295
|
}
|
|
278
296
|
if (source?.kind === "mirror") {
|
|
279
|
-
|
|
297
|
+
const mirrorId = projection.marks.get(span.start.blockIndex)?.[0]?.annotationId;
|
|
298
|
+
|
|
299
|
+
if (mirrorId) return reply(mirrorId, body);
|
|
280
300
|
|
|
281
301
|
return;
|
|
282
302
|
}
|
|
283
303
|
if (source?.kind === "artifact" && end?.kind === "artifact") {
|
|
284
|
-
child?.onAnnotate(span, body);
|
|
285
|
-
|
|
286
|
-
return;
|
|
304
|
+
return child?.onAnnotate(span, body);
|
|
287
305
|
}
|
|
288
306
|
if (
|
|
289
307
|
source?.kind !== "message" ||
|
|
@@ -307,6 +325,8 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
307
325
|
pendingWrites.current.push(
|
|
308
326
|
agent.act((client) => client.agentComment({ id: thread.id, comment })),
|
|
309
327
|
);
|
|
328
|
+
|
|
329
|
+
return comment.id;
|
|
310
330
|
}}
|
|
311
331
|
onReply={reply}
|
|
312
332
|
onUpdateAnnotation={(id, body) => {
|