@cueloop/client 0.1.0-alpha.97 → 0.1.0-alpha.98
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.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +29 -6
- package/src/annotations/use-annotation-surface.tsx +245 -30
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +217 -97
- package/src/app/app-screens.tsx +2 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/diff/components/AnnotatableFileView.tsx +6 -0
- package/src/diff/components/DiffContentView.tsx +9 -0
- package/src/markdown/components/ThreadView.tsx +60 -16
- package/src/settings/config.test.ts +15 -0
- package/src/settings/config.ts +14 -4
- package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +332 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +110 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +780 -0
- package/src/thread/components/AgentThreadPane.tsx +356 -0
- package/src/thread/components/ThreadFooter.tsx +3 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +25 -5
- package/src/thread/thread-controller.ts +34 -0
- package/src/thread/use-thread-agent.test.tsx +65 -0
- package/src/thread/use-thread-agent.ts +101 -0
- package/src/workbench/components/GridTabContent.tsx +6 -0
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.98",
|
|
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.98",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.98",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.98",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.98",
|
|
34
34
|
"@opentui/core": "^0.5.12",
|
|
35
35
|
"@opentui/keymap": "0.5.12",
|
|
36
36
|
"@opentui/react": "^0.5.12",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { CommentRow } from "./AnnotationCards";
|
|
5
|
+
import { DARK } from "../../appearance/theme";
|
|
6
|
+
import { TooltipProvider } from "../../ui/components/Tooltip";
|
|
7
|
+
import { locateText, settle } from "../../testing/test-support";
|
|
8
|
+
|
|
9
|
+
test("submitted comment checks stay visible on hover and navigate to the reply", async () => {
|
|
10
|
+
let navigations = 0;
|
|
11
|
+
const setup = await testRender(
|
|
12
|
+
<TooltipProvider>
|
|
13
|
+
<CommentRow
|
|
14
|
+
annotation={{
|
|
15
|
+
id: "submitted",
|
|
16
|
+
kind: "comment",
|
|
17
|
+
body: "Explain the change",
|
|
18
|
+
anchor: { quote: "change", prefix: "", suffix: "", blockIndex: 0, start: 0, end: 6 },
|
|
19
|
+
createdAt: "2026-10-04",
|
|
20
|
+
}}
|
|
21
|
+
tokens={DARK}
|
|
22
|
+
action={{ label: "View reply", run: () => navigations++ }}
|
|
23
|
+
/>
|
|
24
|
+
</TooltipProvider>,
|
|
25
|
+
{ width: 60, height: 8 },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await settle(setup);
|
|
30
|
+
const checks = locateText(setup, "✓✓");
|
|
31
|
+
|
|
32
|
+
await setup.mockMouse.moveTo(checks.column, checks.row);
|
|
33
|
+
await settle(setup);
|
|
34
|
+
expect(setup.captureCharFrame()).toContain("✓✓");
|
|
35
|
+
expect(setup.captureCharFrame()).toContain("View reply");
|
|
36
|
+
expect(setup.captureCharFrame()).not.toContain("↓");
|
|
37
|
+
await setup.mockMouse.click(checks.column, checks.row);
|
|
38
|
+
expect(navigations).toBe(1);
|
|
39
|
+
} finally {
|
|
40
|
+
setup.renderer.destroy();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
@@ -26,9 +26,8 @@ import { referenceStyleFor } from "../../ui/components/syntax-highlight";
|
|
|
26
26
|
|
|
27
27
|
/* -------------------------------------------------------------- composer */
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// cmd+enter); plain enter breaks the line
|
|
29
|
+
// Option+Enter saves a comment; Ctrl+Enter may additionally invoke the agent.
|
|
30
|
+
// Plain Enter breaks the line. Command+Enter belongs to the terminal.
|
|
32
31
|
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
33
32
|
{ name: "return", super: true, action: "submit" },
|
|
34
33
|
{ name: "return", meta: true, action: "submit" },
|
|
@@ -57,14 +56,16 @@ export function Composer({
|
|
|
57
56
|
onReady,
|
|
58
57
|
onInput,
|
|
59
58
|
placeholder,
|
|
59
|
+
agentEnabled = false,
|
|
60
60
|
}: {
|
|
61
61
|
seed: string;
|
|
62
|
-
glyph: string;
|
|
62
|
+
glyph: string | null;
|
|
63
63
|
tokens: Theme;
|
|
64
64
|
onSave: (body: string) => void;
|
|
65
65
|
onReady: () => void;
|
|
66
66
|
onInput: (text: string, caret: number) => void;
|
|
67
67
|
placeholder?: string;
|
|
68
|
+
agentEnabled?: boolean;
|
|
68
69
|
}): React.ReactNode {
|
|
69
70
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
70
71
|
const pastedImageCount = useRef(0);
|
|
@@ -107,7 +108,9 @@ export function Composer({
|
|
|
107
108
|
|
|
108
109
|
return (
|
|
109
110
|
<box style={{ flexDirection: "row" }}>
|
|
110
|
-
|
|
111
|
+
{glyph !== null ? (
|
|
112
|
+
<text selectable={false} fg={tokens.text} style={{ flexShrink: 0 }}>{`${glyph} `}</text>
|
|
113
|
+
) : null}
|
|
111
114
|
<textarea
|
|
112
115
|
ref={editorRef}
|
|
113
116
|
focused
|
|
@@ -115,7 +118,11 @@ export function Composer({
|
|
|
115
118
|
placeholder={placeholder}
|
|
116
119
|
placeholderColor={tokens.textDim}
|
|
117
120
|
cursorStyle={{ style: "block", blinking: true }}
|
|
118
|
-
keyBindings={
|
|
121
|
+
keyBindings={
|
|
122
|
+
agentEnabled
|
|
123
|
+
? COMPOSE_KEY_BINDINGS.filter((binding) => !binding.super)
|
|
124
|
+
: COMPOSE_KEY_BINDINGS
|
|
125
|
+
}
|
|
119
126
|
onSubmit={() => onSave(editorRef.current?.plainText ?? "")}
|
|
120
127
|
onContentChange={() => {
|
|
121
128
|
const editor = editorRef.current;
|
|
@@ -304,11 +311,13 @@ export function CommentRow({
|
|
|
304
311
|
annotation,
|
|
305
312
|
tokens,
|
|
306
313
|
authorLabel,
|
|
314
|
+
action,
|
|
307
315
|
}: {
|
|
308
316
|
annotation: Annotation;
|
|
309
317
|
tokens: Theme;
|
|
310
318
|
/** The author's resolved display name, shown as a tooltip when the dot is hovered. */
|
|
311
319
|
authorLabel?: string;
|
|
320
|
+
action?: { label: string; run: () => void };
|
|
312
321
|
}): React.ReactNode {
|
|
313
322
|
// own comments (no author) wear the filled dot, collaborators the outline
|
|
314
323
|
const own = annotation.author === undefined;
|
|
@@ -334,6 +343,20 @@ export function CommentRow({
|
|
|
334
343
|
<text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
|
|
335
344
|
{line}
|
|
336
345
|
</text>
|
|
346
|
+
{lineIndex === 0 && action ? (
|
|
347
|
+
<text
|
|
348
|
+
fg={tokens.blue}
|
|
349
|
+
onMouseUp={action.run}
|
|
350
|
+
onMouseOver={(event: TerminalMouseEvent) => {
|
|
351
|
+
showTooltip(action.label, event.x, event.y);
|
|
352
|
+
}}
|
|
353
|
+
onMouseOut={() => {
|
|
354
|
+
hideTooltip();
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
{action.label === "Retry" ? "Retry" : "✓✓"}
|
|
358
|
+
</text>
|
|
359
|
+
) : null}
|
|
337
360
|
</box>
|
|
338
361
|
))}
|
|
339
362
|
</box>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* list of text lines it does not know how to paint. A character-precise caret
|
|
4
4
|
* sits in the text; click/drag marks (word mode on double-click); typing opens a
|
|
5
5
|
* composer anchored at the caret word or held selection; "/" opens the quick-action
|
|
6
|
-
* palette; enter replies, tab folds, esc dismisses;
|
|
6
|
+
* palette; enter replies, tab folds, esc dismisses; Option+Enter saves, Ctrl+Enter invokes. The plan
|
|
7
7
|
* thread view and the diff sheet both drive this hook and only paint their own
|
|
8
8
|
* rows, so marking and commenting behave identically on prose and on code.
|
|
9
9
|
*
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* cards this hook builds under the visual line a span ends on.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
15
|
+
import React, { useContext, useEffect, useEffectEvent, useRef, useState } from "react";
|
|
16
16
|
import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
|
|
17
17
|
import { flushSync } from "@opentui/react";
|
|
18
18
|
import { useSharedKeyboard } from "../keyboard/use-shared-keyboard";
|
|
19
|
-
import type
|
|
19
|
+
import { agentInputTarget, type Annotation, type Thread } from "@cueloop/schema";
|
|
20
20
|
import type { Mark } from "../markdown/view-plan";
|
|
21
21
|
import type { QuickAction } from "../settings/config";
|
|
22
22
|
import type { Theme } from "../appearance/theme";
|
|
@@ -64,15 +64,27 @@ export interface LineSource {
|
|
|
64
64
|
annotatable(blockIndex: number): boolean;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
/** Prompt drafts cannot carry a discussion or edit target; comment drafts retain those targets. */
|
|
68
|
+
export type ComposeState = { blockIndex: number; seed: string } & (
|
|
69
|
+
| { kind: "prompt"; discussionKey: null; editAnnotationId: null; span: TextSpan }
|
|
70
|
+
| {
|
|
71
|
+
kind: "comment";
|
|
72
|
+
discussionKey: string | null;
|
|
73
|
+
editAnnotationId: string | null;
|
|
74
|
+
span: TextSpan | null;
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** A completed reply requests an empty continuation prompt once per reply ID. */
|
|
79
|
+
export interface PromptFocusRequest {
|
|
80
|
+
replyId: string;
|
|
69
81
|
blockIndex: number;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Rejected prompt text returns to the visible composer once per request. */
|
|
85
|
+
export interface PromptRestoreRequest {
|
|
86
|
+
id: number;
|
|
87
|
+
text: string;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export interface AnnotationSurfaceOptions {
|
|
@@ -112,6 +124,14 @@ export interface AnnotationSurfaceOptions {
|
|
|
112
124
|
onAnnotate: (span: TextSpan, body: string) => void;
|
|
113
125
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
114
126
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
127
|
+
isAnnotationReadOnly?: (id: string) => boolean;
|
|
128
|
+
annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
|
|
129
|
+
requestedBlock?: { blockIndex: number };
|
|
130
|
+
onInvoke?: () => void;
|
|
131
|
+
/** The final prompt block accepts unmarked typing without creating a discussion. */
|
|
132
|
+
isPromptBlock?: (blockIndex: number) => boolean;
|
|
133
|
+
promptFocusRequest?: PromptFocusRequest;
|
|
134
|
+
promptRestoreRequest?: PromptRestoreRequest;
|
|
115
135
|
/** The visible scroll viewport used to keep a held mouse mark moving at its edges. */
|
|
116
136
|
dragViewport?: () => {
|
|
117
137
|
top: number;
|
|
@@ -153,6 +173,7 @@ export interface AnnotationSurface {
|
|
|
153
173
|
onLineMouseDown: (event: TerminalMouseEvent) => void;
|
|
154
174
|
/** Drag routing for the view root, so a fast flick off a row never strands the gesture. */
|
|
155
175
|
rootMouseProps: {
|
|
176
|
+
onMouseDown: (event: TerminalMouseEvent) => void;
|
|
156
177
|
onMouseDrag: (event: TerminalMouseEvent) => void;
|
|
157
178
|
onMouseDragEnd: () => void;
|
|
158
179
|
onMouseUp: () => void;
|
|
@@ -210,6 +231,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
210
231
|
onAnnotate,
|
|
211
232
|
onReply,
|
|
212
233
|
onUpdateAnnotation,
|
|
234
|
+
isAnnotationReadOnly,
|
|
235
|
+
annotationAction,
|
|
236
|
+
requestedBlock,
|
|
237
|
+
onInvoke,
|
|
238
|
+
isPromptBlock,
|
|
239
|
+
promptFocusRequest,
|
|
240
|
+
promptRestoreRequest,
|
|
213
241
|
dragViewport,
|
|
214
242
|
resolveAuthorLabel,
|
|
215
243
|
onNavCommand,
|
|
@@ -226,6 +254,15 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
226
254
|
|
|
227
255
|
return { head: start, anchor: start };
|
|
228
256
|
});
|
|
257
|
+
const focusRequested = useEffectEvent(() => onFocusAnnotation?.(undefined));
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (requestedBlock === undefined) return;
|
|
260
|
+
const next = { blockIndex: requestedBlock.blockIndex, char: 0 };
|
|
261
|
+
|
|
262
|
+
setCursor(requestedBlock.blockIndex);
|
|
263
|
+
setCaret({ head: next, anchor: next });
|
|
264
|
+
focusRequested();
|
|
265
|
+
}, [requestedBlock]);
|
|
229
266
|
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
230
267
|
const dragPointer = useRef<{ x: number; y: number } | null>(null);
|
|
231
268
|
const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -291,6 +328,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
291
328
|
const [slashIndex, setSlashIndex] = useState(0);
|
|
292
329
|
const composerReady = useRef(false);
|
|
293
330
|
const composeRef = useRef<ComposeState | null>(null);
|
|
331
|
+
const promptDraft = useRef("");
|
|
294
332
|
// every visual line registers its renderable so a drag can hit-test any
|
|
295
333
|
// row on screen, across blocks (rows without text resolve to the block above)
|
|
296
334
|
const lineRenderables = useRef(
|
|
@@ -317,9 +355,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
317
355
|
return span ? tightenSpan(span, textLengthOf) : null;
|
|
318
356
|
})();
|
|
319
357
|
const caretIsSelection = heldSpan !== null;
|
|
320
|
-
/**
|
|
358
|
+
/** Agent comments require a held selection; legacy surfaces may use the caret word. */
|
|
321
359
|
const caretSpan = (): TextSpan | null => {
|
|
322
360
|
if (heldSpan) return heldSpan;
|
|
361
|
+
if (onInvoke) return isPromptBlock?.(head.blockIndex) ? { start: head, end: head } : null;
|
|
323
362
|
const word = wordRangeAt(blockText(head.blockIndex), head.char);
|
|
324
363
|
|
|
325
364
|
return word
|
|
@@ -327,7 +366,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
327
366
|
start: { blockIndex: cursor, char: word.start },
|
|
328
367
|
end: { blockIndex: cursor, char: word.end },
|
|
329
368
|
}
|
|
330
|
-
:
|
|
369
|
+
: blockText(head.blockIndex).length === 0
|
|
370
|
+
? { start: head, end: head }
|
|
371
|
+
: null;
|
|
331
372
|
};
|
|
332
373
|
const collapseCaret = (): void => setCaret({ head, anchor: head });
|
|
333
374
|
const spanQuote = (span: TextSpan): string => {
|
|
@@ -444,16 +485,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
444
485
|
if (slashActive) setSlashIndex(0);
|
|
445
486
|
}, [slashActive]);
|
|
446
487
|
|
|
447
|
-
const saveComment = (body: string): void => {
|
|
488
|
+
const saveComment = (body: string, invoke = false): void => {
|
|
448
489
|
// the body saves verbatim - typed newlines are the author's choice;
|
|
449
490
|
// trimming only decides whether the draft is empty enough to discard
|
|
450
491
|
const target = composeRef.current;
|
|
492
|
+
const prompt = target?.kind === "prompt";
|
|
451
493
|
|
|
494
|
+
if (prompt && !invoke) return;
|
|
452
495
|
closeCompose();
|
|
453
496
|
collapseCaret();
|
|
454
497
|
if (!target || body.trim().length === 0) return;
|
|
455
498
|
if (target.editAnnotationId !== null) {
|
|
456
499
|
onUpdateAnnotation(target.editAnnotationId, body);
|
|
500
|
+
if (invoke) onInvoke?.();
|
|
457
501
|
|
|
458
502
|
return;
|
|
459
503
|
}
|
|
@@ -462,35 +506,64 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
462
506
|
|
|
463
507
|
if (discussion) {
|
|
464
508
|
onReply(discussion.rootId, body);
|
|
509
|
+
if (invoke) onInvoke?.();
|
|
465
510
|
|
|
466
511
|
return;
|
|
467
512
|
}
|
|
468
513
|
}
|
|
469
514
|
const span = target.span ?? caretSpan();
|
|
470
515
|
|
|
471
|
-
if (span)
|
|
516
|
+
if (span) {
|
|
517
|
+
onAnnotate(span, body);
|
|
518
|
+
if (invoke) onInvoke?.();
|
|
519
|
+
}
|
|
472
520
|
};
|
|
473
521
|
/** A new discussion on the typing anchor; the card renders under the span's last block. */
|
|
474
522
|
const openNewCompose = (seed: string): void => {
|
|
475
523
|
const span = caretSpan();
|
|
476
524
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
525
|
+
if (
|
|
526
|
+
onInvoke &&
|
|
527
|
+
agentInputTarget(Boolean(isPromptBlock?.(head.blockIndex)), Boolean(heldSpan)) === "none"
|
|
528
|
+
)
|
|
529
|
+
return;
|
|
530
|
+
if (isPromptBlock?.(head.blockIndex) && span) {
|
|
531
|
+
openCompose({
|
|
532
|
+
kind: "prompt",
|
|
533
|
+
blockIndex: head.blockIndex,
|
|
534
|
+
discussionKey: null,
|
|
535
|
+
span,
|
|
536
|
+
seed: promptDraft.current + seed,
|
|
537
|
+
editAnnotationId: null,
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
openCompose({
|
|
541
|
+
kind: "comment",
|
|
542
|
+
blockIndex: span?.end.blockIndex ?? head.blockIndex,
|
|
543
|
+
discussionKey: null,
|
|
544
|
+
span,
|
|
545
|
+
seed,
|
|
546
|
+
editAnnotationId: null,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (isPromptBlock?.(head.blockIndex)) promptDraft.current = "";
|
|
484
550
|
};
|
|
485
551
|
|
|
486
552
|
// clicking away commits the draft (blur-save); only a standalone "/query" is a palette
|
|
487
553
|
// artifact, so prose that merely ends in a "/name" still saves
|
|
488
554
|
const blurSaveCompose = (): void => {
|
|
489
|
-
|
|
555
|
+
const target = composeRef.current;
|
|
556
|
+
|
|
557
|
+
if (!target) return;
|
|
558
|
+
if (target.kind === "prompt") {
|
|
559
|
+
promptDraft.current = composeTextRef.current;
|
|
560
|
+
|
|
561
|
+
return closeCompose();
|
|
562
|
+
}
|
|
490
563
|
if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
|
|
491
564
|
return closeCompose();
|
|
492
565
|
}
|
|
493
|
-
saveComment(composeText);
|
|
566
|
+
saveComment(composeText, false);
|
|
494
567
|
};
|
|
495
568
|
|
|
496
569
|
/** Extend the held selection to the pointer's position (word mode snaps both ends). */
|
|
@@ -590,10 +663,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
590
663
|
return false;
|
|
591
664
|
};
|
|
592
665
|
|
|
593
|
-
/** Pre-mount window: buffer printables, honor a fast
|
|
666
|
+
/** Pre-mount window: buffer printables, honor a fast comment save, agent invocation, or newline. */
|
|
594
667
|
const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
595
668
|
if (key.name === "return") {
|
|
596
|
-
if (key.super
|
|
669
|
+
if (key.super && onInvoke) return;
|
|
670
|
+
if (key.meta || key.ctrl || key.super)
|
|
671
|
+
return saveComment(activeCompose.seed, isAgentInvokeKey(key, onInvoke));
|
|
597
672
|
const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
|
|
598
673
|
|
|
599
674
|
composeRef.current = grown;
|
|
@@ -615,6 +690,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
615
690
|
};
|
|
616
691
|
|
|
617
692
|
const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
693
|
+
if (onInvoke && (key.name === "return" || key.name === "enter") && key.super) {
|
|
694
|
+
key.preventDefault();
|
|
695
|
+
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (isAgentInvokeKey(key, onInvoke)) {
|
|
699
|
+
key.preventDefault();
|
|
700
|
+
|
|
701
|
+
return saveComment(composerReady.current ? composeTextRef.current : activeCompose.seed, true);
|
|
702
|
+
}
|
|
618
703
|
// the textarea owns every key while open; the view takes dismiss (which
|
|
619
704
|
// also releases the discussion focus), the slash palette, and pre-mount input
|
|
620
705
|
if (key.name === "escape") {
|
|
@@ -683,6 +768,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
683
768
|
|
|
684
769
|
if (replyTarget) {
|
|
685
770
|
openCompose({
|
|
771
|
+
kind: "comment",
|
|
686
772
|
blockIndex: replyTarget.blockIndex,
|
|
687
773
|
discussionKey: replyTarget.key,
|
|
688
774
|
span: null,
|
|
@@ -778,9 +864,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
778
864
|
|
|
779
865
|
if (discussion) {
|
|
780
866
|
const last = discussion.annotations.at(-1)!;
|
|
781
|
-
const editingOwn = last.author === undefined;
|
|
867
|
+
const editingOwn = last.author === undefined && !isAnnotationReadOnly?.(last.id);
|
|
782
868
|
|
|
783
869
|
return openCompose({
|
|
870
|
+
kind: "comment",
|
|
784
871
|
blockIndex: discussion.blockIndex,
|
|
785
872
|
discussionKey: discussion.key,
|
|
786
873
|
span: null,
|
|
@@ -797,6 +884,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
797
884
|
const activeCompose = composeRef.current;
|
|
798
885
|
|
|
799
886
|
if (activeCompose) return handleComposeKey(key, activeCompose);
|
|
887
|
+
if (isAgentInvokeKey(key, onInvoke)) {
|
|
888
|
+
key.preventDefault();
|
|
889
|
+
return onInvoke?.();
|
|
890
|
+
}
|
|
800
891
|
if (key.name === "escape") {
|
|
801
892
|
// from type mode esc only enters nav, so a held mark survives for `c`
|
|
802
893
|
if (!navModeRef.current) return setNavMode(true);
|
|
@@ -833,9 +924,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
833
924
|
<Composer
|
|
834
925
|
key={compose.seed}
|
|
835
926
|
seed={compose.seed}
|
|
836
|
-
glyph="●"
|
|
927
|
+
glyph={compose.kind === "prompt" ? null : "●"}
|
|
837
928
|
tokens={tokens}
|
|
838
929
|
onSave={saveComment}
|
|
930
|
+
agentEnabled={Boolean(onInvoke)}
|
|
839
931
|
onReady={() => (composerReady.current = true)}
|
|
840
932
|
onInput={(text, caret) => {
|
|
841
933
|
setDraft(text);
|
|
@@ -877,6 +969,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
877
969
|
annotation={annotation}
|
|
878
970
|
tokens={tokens}
|
|
879
971
|
authorLabel={resolveAuthorLabel?.(annotation)}
|
|
972
|
+
action={annotationAction?.(annotation.id)}
|
|
880
973
|
/>
|
|
881
974
|
),
|
|
882
975
|
};
|
|
@@ -930,7 +1023,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
930
1023
|
line: VisualLine,
|
|
931
1024
|
isLastLine: boolean,
|
|
932
1025
|
): React.ReactNode[] => {
|
|
933
|
-
const endsInLine = (end: number): boolean =>
|
|
1026
|
+
const endsInLine = (end: number): boolean =>
|
|
1027
|
+
(end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
|
|
934
1028
|
const nodes: React.ReactNode[] = [];
|
|
935
1029
|
const composeHere = compose && compose.blockIndex === blockIndex;
|
|
936
1030
|
const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
|
|
@@ -939,6 +1033,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
939
1033
|
const composeStart = compose?.span?.start;
|
|
940
1034
|
|
|
941
1035
|
const pushComposeCard = (): void => {
|
|
1036
|
+
if (compose?.kind === "prompt") {
|
|
1037
|
+
nodes.push(
|
|
1038
|
+
<box key="compose-prompt" style={{ paddingLeft: 2 }}>
|
|
1039
|
+
{composerNode}
|
|
1040
|
+
</box>,
|
|
1041
|
+
);
|
|
1042
|
+
nodes.push(paletteNode);
|
|
1043
|
+
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
942
1046
|
nodes.push(
|
|
943
1047
|
<DiscussionCard
|
|
944
1048
|
key="compose-new"
|
|
@@ -992,6 +1096,99 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
992
1096
|
} else lineRenderables.current.delete(key);
|
|
993
1097
|
};
|
|
994
1098
|
|
|
1099
|
+
const focusPrompt = (blockIndex: number): void => {
|
|
1100
|
+
const position = { blockIndex, char: 0 };
|
|
1101
|
+
|
|
1102
|
+
setNavMode(false);
|
|
1103
|
+
setFocusedDiscussion(null);
|
|
1104
|
+
setCursor(blockIndex);
|
|
1105
|
+
setCaret({ head: position, anchor: position });
|
|
1106
|
+
openCompose({
|
|
1107
|
+
kind: "prompt",
|
|
1108
|
+
blockIndex,
|
|
1109
|
+
discussionKey: null,
|
|
1110
|
+
span: { start: position, end: position },
|
|
1111
|
+
seed: promptDraft.current,
|
|
1112
|
+
editAnnotationId: null,
|
|
1113
|
+
});
|
|
1114
|
+
promptDraft.current = "";
|
|
1115
|
+
};
|
|
1116
|
+
const movePromptDraft = useEffectEvent(() => {
|
|
1117
|
+
const active = composeRef.current;
|
|
1118
|
+
const blockIndex = source.count - 1;
|
|
1119
|
+
|
|
1120
|
+
if (
|
|
1121
|
+
active?.kind !== "prompt" ||
|
|
1122
|
+
!isPromptBlock?.(blockIndex) ||
|
|
1123
|
+
active.blockIndex === blockIndex
|
|
1124
|
+
)
|
|
1125
|
+
return;
|
|
1126
|
+
const position = { blockIndex, char: 0 };
|
|
1127
|
+
|
|
1128
|
+
openCompose({
|
|
1129
|
+
...active,
|
|
1130
|
+
blockIndex,
|
|
1131
|
+
span: { start: position, end: position },
|
|
1132
|
+
seed: composeTextRef.current,
|
|
1133
|
+
});
|
|
1134
|
+
setCursor(blockIndex);
|
|
1135
|
+
setCaret({ head: position, anchor: position });
|
|
1136
|
+
});
|
|
1137
|
+
useEffect(() => {
|
|
1138
|
+
movePromptDraft();
|
|
1139
|
+
}, [source.count]);
|
|
1140
|
+
const restoredPrompt = useRef<number | null>(null);
|
|
1141
|
+
const restorePrompt = useEffectEvent(() => {
|
|
1142
|
+
if (!promptRestoreRequest || suspended || !onInvoke) return;
|
|
1143
|
+
if (restoredPrompt.current === promptRestoreRequest.id) return;
|
|
1144
|
+
restoredPrompt.current = promptRestoreRequest.id;
|
|
1145
|
+
const active = composeRef.current;
|
|
1146
|
+
const newerDraft = active?.kind === "prompt" ? composeTextRef.current : promptDraft.current;
|
|
1147
|
+
|
|
1148
|
+
if (active?.kind !== "prompt") blurSaveCompose();
|
|
1149
|
+
promptDraft.current = [promptRestoreRequest.text, newerDraft].filter(Boolean).join("\n");
|
|
1150
|
+
focusPrompt(source.count - 1);
|
|
1151
|
+
});
|
|
1152
|
+
useEffect(() => {
|
|
1153
|
+
restorePrompt();
|
|
1154
|
+
}, [promptRestoreRequest?.id, suspended]);
|
|
1155
|
+
const focusedReply = useRef<string | null>(null);
|
|
1156
|
+
const continueConversation = useEffectEvent(() => {
|
|
1157
|
+
if (!promptFocusRequest || suspended || !onInvoke) return;
|
|
1158
|
+
if (focusedReply.current === promptFocusRequest.replyId) return;
|
|
1159
|
+
focusedReply.current = promptFocusRequest.replyId;
|
|
1160
|
+
// An arriving answer cannot replace a draft or a marked passage under review.
|
|
1161
|
+
if (composeRef.current || heldSpan || dragging.current || promptDraft.current) return;
|
|
1162
|
+
focusPrompt(promptFocusRequest.blockIndex);
|
|
1163
|
+
});
|
|
1164
|
+
useEffect(() => {
|
|
1165
|
+
continueConversation();
|
|
1166
|
+
}, [promptFocusRequest?.replyId, promptFocusRequest?.blockIndex, suspended]);
|
|
1167
|
+
const onPromptMouseDown = (event: TerminalMouseEvent): void => {
|
|
1168
|
+
if (!onInvoke || suspended) return;
|
|
1169
|
+
const blockIndex = source.count - 1;
|
|
1170
|
+
const geometry = allGeometry();
|
|
1171
|
+
const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
|
|
1172
|
+
const lastReplyLine = geometry
|
|
1173
|
+
.filter((entry) => entry.blockIndex === blockIndex - 1)
|
|
1174
|
+
.sort((left, right) => right.y - left.y)[0];
|
|
1175
|
+
const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
|
|
1176
|
+
const viewport = dragViewport?.();
|
|
1177
|
+
|
|
1178
|
+
if (
|
|
1179
|
+
!isPromptBlock?.(blockIndex) ||
|
|
1180
|
+
promptStart === undefined ||
|
|
1181
|
+
event.y < promptStart ||
|
|
1182
|
+
(viewport && event.y >= viewport.bottom)
|
|
1183
|
+
)
|
|
1184
|
+
return;
|
|
1185
|
+
if (composeRef.current?.kind === "prompt") {
|
|
1186
|
+
promptDraft.current = composeTextRef.current;
|
|
1187
|
+
} else blurSaveCompose();
|
|
1188
|
+
endDrag();
|
|
1189
|
+
focusPrompt(blockIndex);
|
|
1190
|
+
};
|
|
1191
|
+
|
|
995
1192
|
const onLineMouseDown = (event: TerminalMouseEvent): void => {
|
|
996
1193
|
blurSaveCompose();
|
|
997
1194
|
const pressed = positionAt(allGeometry(), event.x, event.y);
|
|
@@ -1033,10 +1230,28 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
1033
1230
|
spanQuote,
|
|
1034
1231
|
registerLine,
|
|
1035
1232
|
onLineMouseDown,
|
|
1036
|
-
rootMouseProps: {
|
|
1233
|
+
rootMouseProps: {
|
|
1234
|
+
onMouseDown: onPromptMouseDown,
|
|
1235
|
+
onMouseDrag: handleRootDrag,
|
|
1236
|
+
onMouseDragEnd: endDrag,
|
|
1237
|
+
onMouseUp: endDrag,
|
|
1238
|
+
},
|
|
1037
1239
|
rangesFor,
|
|
1038
1240
|
cardsAfterLine,
|
|
1039
1241
|
jumpToDiscussion,
|
|
1040
1242
|
blurSaveCompose,
|
|
1041
1243
|
};
|
|
1042
1244
|
}
|
|
1245
|
+
|
|
1246
|
+
function isAgentInvokeKey(
|
|
1247
|
+
key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
|
|
1248
|
+
onInvoke?: () => void,
|
|
1249
|
+
): boolean {
|
|
1250
|
+
return Boolean(
|
|
1251
|
+
onInvoke &&
|
|
1252
|
+
(key.name === "return" || key.name === "enter") &&
|
|
1253
|
+
key.ctrl &&
|
|
1254
|
+
!key.meta &&
|
|
1255
|
+
!key.super,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { canRunThreadAgent } from "./App";
|
|
3
|
+
|
|
4
|
+
test("experimental agent UI requires a local owner and an enabled flag", () => {
|
|
5
|
+
const local = { enabled: true, owner: true, editing: false, shared: false };
|
|
6
|
+
|
|
7
|
+
expect(canRunThreadAgent(local)).toBe(true);
|
|
8
|
+
expect(canRunThreadAgent({ ...local, enabled: false })).toBe(false);
|
|
9
|
+
expect(canRunThreadAgent({ ...local, owner: false })).toBe(false);
|
|
10
|
+
expect(canRunThreadAgent({ ...local, editing: true })).toBe(false);
|
|
11
|
+
expect(canRunThreadAgent({ ...local, shared: true })).toBe(false);
|
|
12
|
+
});
|