@cueloop/client 0.1.0-alpha.100 → 0.1.0-alpha.101
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 +5 -0
- package/src/annotations/use-annotation-surface.tsx +7 -2
- package/src/app/App.thread-agent.test.ts +8 -0
- package/src/app/App.tsx +18 -1
- package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
- package/src/integrations/components/ShareDialog.tsx +27 -3
- package/src/integrations/components/share-dialog-store.ts +4 -0
- package/src/markdown/components/ThreadView.tsx +1 -1
- package/src/thread/components/AgentThreadPane.test.tsx +109 -3
- package/src/thread/components/AgentThreadPane.tsx +25 -15
- package/src/thread/thread-controller.ts +30 -22
- package/src/thread/use-thread-agent.ts +7 -1
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.101",
|
|
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.101",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.101",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.101",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.101",
|
|
34
34
|
"@opentui/core": "^0.5.14",
|
|
35
35
|
"@opentui/keymap": "0.5.14",
|
|
36
36
|
"@opentui/react": "^0.5.14",
|
|
@@ -57,6 +57,7 @@ export function Composer({
|
|
|
57
57
|
onInput,
|
|
58
58
|
placeholder,
|
|
59
59
|
agentEnabled = false,
|
|
60
|
+
focusRequest,
|
|
60
61
|
}: {
|
|
61
62
|
seed: string;
|
|
62
63
|
glyph: string | null;
|
|
@@ -66,6 +67,7 @@ export function Composer({
|
|
|
66
67
|
onInput: (text: string, caret: number) => void;
|
|
67
68
|
placeholder?: string;
|
|
68
69
|
agentEnabled?: boolean;
|
|
70
|
+
focusRequest?: number;
|
|
69
71
|
}): React.ReactNode {
|
|
70
72
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
71
73
|
const pastedImageCount = useRef(0);
|
|
@@ -105,6 +107,9 @@ export function Composer({
|
|
|
105
107
|
onReady(() => editorRef.current?.plainText ?? "");
|
|
106
108
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
109
|
}, []);
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
editorRef.current?.focus();
|
|
112
|
+
}, [focusRequest]);
|
|
108
113
|
|
|
109
114
|
return (
|
|
110
115
|
<box style={{ flexDirection: "row" }}>
|
|
@@ -290,6 +290,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
290
290
|
};
|
|
291
291
|
const [folded, setFolded] = useState<Set<string>>(new Set());
|
|
292
292
|
const [composeText, setComposeText] = useState("");
|
|
293
|
+
const [composerFocusRequest, setComposerFocusRequest] = useState(0);
|
|
293
294
|
const composeTextRef = useRef("");
|
|
294
295
|
const setDraft = (text: string): void => {
|
|
295
296
|
composeTextRef.current = text;
|
|
@@ -436,6 +437,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
436
437
|
readComposerText.current = null;
|
|
437
438
|
composeRef.current = state;
|
|
438
439
|
setCompose(state);
|
|
440
|
+
setComposerFocusRequest((request) => request + 1);
|
|
439
441
|
setDraft(state.seed);
|
|
440
442
|
setCaretOffset(state.seed.length);
|
|
441
443
|
setSlashIndex(0);
|
|
@@ -903,6 +905,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
903
905
|
tokens={tokens}
|
|
904
906
|
onSave={saveComment}
|
|
905
907
|
agentEnabled={Boolean(onInvoke)}
|
|
908
|
+
focusRequest={composerFocusRequest}
|
|
906
909
|
onReady={(readText) => {
|
|
907
910
|
readComposerText.current = readText;
|
|
908
911
|
composerReady.current = true;
|
|
@@ -996,6 +999,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
996
999
|
return ranges;
|
|
997
1000
|
};
|
|
998
1001
|
|
|
1002
|
+
const composeBlockIndex = compose?.kind === "prompt" ? source.count - 1 : compose?.blockIndex;
|
|
999
1003
|
const cardsAfterLine = (
|
|
1000
1004
|
blockIndex: number,
|
|
1001
1005
|
line: VisualLine,
|
|
@@ -1004,7 +1008,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
1004
1008
|
const endsInLine = (end: number): boolean =>
|
|
1005
1009
|
(end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
|
|
1006
1010
|
const nodes: React.ReactNode[] = [];
|
|
1007
|
-
const composeHere = compose &&
|
|
1011
|
+
const composeHere = compose && composeBlockIndex === blockIndex;
|
|
1008
1012
|
const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
|
|
1009
1013
|
const composeAnchoredHere =
|
|
1010
1014
|
newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
|
|
@@ -1151,7 +1155,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
1151
1155
|
const geometry = allGeometry();
|
|
1152
1156
|
const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
|
|
1153
1157
|
const lastReplyLine = geometry
|
|
1154
|
-
|
|
1158
|
+
// Activity blocks render outside the text geometry, so use the last measured line.
|
|
1159
|
+
.filter((entry) => entry.blockIndex < blockIndex)
|
|
1155
1160
|
.sort((left, right) => right.y - left.y)[0];
|
|
1156
1161
|
const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
|
|
1157
1162
|
const viewport = dragViewport?.();
|
|
@@ -10,3 +10,11 @@ test("experimental agent UI requires a local owner and an enabled flag", () => {
|
|
|
10
10
|
expect(canRunThreadAgent({ ...local, editing: true })).toBe(false);
|
|
11
11
|
expect(canRunThreadAgent({ ...local, shared: true })).toBe(false);
|
|
12
12
|
});
|
|
13
|
+
|
|
14
|
+
test("a per-share agent capability enables collaborator chat without a gateway flag", () => {
|
|
15
|
+
const shared = { enabled: false, owner: false, editing: false, shared: true };
|
|
16
|
+
|
|
17
|
+
expect(canRunThreadAgent(shared)).toBe(false);
|
|
18
|
+
expect(canRunThreadAgent({ ...shared, sharedAgent: true })).toBe(true);
|
|
19
|
+
expect(canRunThreadAgent({ ...shared, sharedAgent: true, editing: true })).toBe(false);
|
|
20
|
+
});
|
package/src/app/App.tsx
CHANGED
|
@@ -119,6 +119,7 @@ export interface AppProps {
|
|
|
119
119
|
sessionId?: string;
|
|
120
120
|
initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
|
|
121
121
|
initialClient?: ThreadClient;
|
|
122
|
+
agentClient?: import("../thread/use-thread-agent").ThreadAgentClient;
|
|
122
123
|
cwd?: string;
|
|
123
124
|
readOnly?: boolean;
|
|
124
125
|
onExit?: (code: number) => void;
|
|
@@ -531,6 +532,7 @@ export function App({
|
|
|
531
532
|
sessionId,
|
|
532
533
|
initialReview,
|
|
533
534
|
initialClient,
|
|
535
|
+
agentClient,
|
|
534
536
|
cwd,
|
|
535
537
|
readOnly = false,
|
|
536
538
|
onExit,
|
|
@@ -1315,11 +1317,13 @@ export function App({
|
|
|
1315
1317
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
1316
1318
|
<AgentThreadPrototype
|
|
1317
1319
|
key={activeSession.id}
|
|
1320
|
+
client={agentClient}
|
|
1318
1321
|
enabled={canRunThreadAgent({
|
|
1319
1322
|
enabled: threadAgentEnabled,
|
|
1320
1323
|
owner: isOwner,
|
|
1321
1324
|
editing: bodyEditing.editing,
|
|
1322
1325
|
shared: isSharedThreadConnection(openClient, shareTransport),
|
|
1326
|
+
sharedAgent: sharedAgentAvailable(agentClient, activeSession),
|
|
1323
1327
|
})}
|
|
1324
1328
|
observer={observer}
|
|
1325
1329
|
pixelPrototype={isPixelPrototype}
|
|
@@ -1592,6 +1596,12 @@ export function App({
|
|
|
1592
1596
|
threadName={shareThreadName(session)}
|
|
1593
1597
|
links={shareLinksFor(session)}
|
|
1594
1598
|
isOwner={isOwner}
|
|
1599
|
+
agentAvailable={canRunThreadAgent({
|
|
1600
|
+
enabled: threadAgentEnabled,
|
|
1601
|
+
owner: isOwner,
|
|
1602
|
+
editing: false,
|
|
1603
|
+
shared: isSharedThreadConnection(openClient, shareTransport),
|
|
1604
|
+
})}
|
|
1595
1605
|
onCreateLink={(input) => controller.createShareLink(input)}
|
|
1596
1606
|
onUpdateLink={(id, input) => controller.updateShareLink(id, input)}
|
|
1597
1607
|
onDeleteLink={(id) => controller.deleteShareLink(id)}
|
|
@@ -1618,8 +1628,15 @@ export function canRunThreadAgent(options: {
|
|
|
1618
1628
|
owner: boolean;
|
|
1619
1629
|
editing: boolean;
|
|
1620
1630
|
shared: boolean;
|
|
1631
|
+
sharedAgent?: boolean;
|
|
1621
1632
|
}): boolean {
|
|
1622
|
-
|
|
1633
|
+
if (options.editing) return false;
|
|
1634
|
+
|
|
1635
|
+
return Boolean(options.sharedAgent || (options.enabled && options.owner && !options.shared));
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function sharedAgentAvailable(client: AppProps["agentClient"], thread: Thread): boolean {
|
|
1639
|
+
return Boolean(client && thread.shares?.[0]?.agentEnabled);
|
|
1623
1640
|
}
|
|
1624
1641
|
|
|
1625
1642
|
function isSubmittedComment(
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { DARK } from "../../appearance/theme";
|
|
5
|
+
import { press, waitForText } from "../../testing/test-support";
|
|
6
|
+
import { ShareDialog } from "./ShareDialog";
|
|
7
|
+
import { newWizardDraft, shareDialogStore } from "./share-dialog-store";
|
|
8
|
+
import type { NewShareLink } from "../../thread/thread-controller";
|
|
9
|
+
|
|
10
|
+
for (const enabled of [false, true])
|
|
11
|
+
test(`share agent toggle is ${enabled ? "opt-in" : "hidden"}`, async () => {
|
|
12
|
+
const created: NewShareLink[] = [];
|
|
13
|
+
|
|
14
|
+
shareDialogStore.getState().reset();
|
|
15
|
+
shareDialogStore.getState().openWizard(newWizardDraft("Review"));
|
|
16
|
+
const setup = await testRender(
|
|
17
|
+
<ShareDialog
|
|
18
|
+
isOpen
|
|
19
|
+
isOwner
|
|
20
|
+
agentAvailable={enabled}
|
|
21
|
+
links={[]}
|
|
22
|
+
threadName="Review"
|
|
23
|
+
theme={DARK}
|
|
24
|
+
onCreateLink={(input) => created.push(input)}
|
|
25
|
+
onUpdateLink={() => {}}
|
|
26
|
+
onDeleteLink={() => {}}
|
|
27
|
+
onCopyLink={() => {}}
|
|
28
|
+
onClose={() => {}}
|
|
29
|
+
/>,
|
|
30
|
+
{ width: 100, height: 26 },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await waitForText(setup, "link name");
|
|
35
|
+
expect(shareDialogStore.getState().wizard?.agentEnabled ?? false).toBe(false);
|
|
36
|
+
expect(setup.captureCharFrame().includes("Allow agent messages")).toBe(enabled);
|
|
37
|
+
await press(setup, "down");
|
|
38
|
+
await press(setup, "down");
|
|
39
|
+
if (enabled) {
|
|
40
|
+
await press(setup, " ");
|
|
41
|
+
await press(setup, "down");
|
|
42
|
+
}
|
|
43
|
+
await press(setup, "enter");
|
|
44
|
+
expect(created).toHaveLength(1);
|
|
45
|
+
expect(created[0]?.agentEnabled).toBe(enabled ? true : undefined);
|
|
46
|
+
} finally {
|
|
47
|
+
setup.renderer.destroy();
|
|
48
|
+
shareDialogStore.getState().reset();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
@@ -68,6 +68,7 @@ function draftForLink(link: ShareLink, threadName: string): ShareWizardDraft {
|
|
|
68
68
|
editingId: link.id,
|
|
69
69
|
name: link.name ?? threadName,
|
|
70
70
|
requireAuth: link.requireAuth,
|
|
71
|
+
agentEnabled: link.agentEnabled,
|
|
71
72
|
allowlist: link.allowlist,
|
|
72
73
|
};
|
|
73
74
|
}
|
|
@@ -259,7 +260,7 @@ function LinksList({
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
/** The focus ring positions inside the wizard, in vertical order. */
|
|
262
|
-
type WizardFocus = "name" | "auth" | "allowlist" | "actions";
|
|
263
|
+
type WizardFocus = "name" | "auth" | "allowlist" | "agent" | "actions";
|
|
263
264
|
|
|
264
265
|
function Wizard({
|
|
265
266
|
draft,
|
|
@@ -267,6 +268,8 @@ function Wizard({
|
|
|
267
268
|
saveDisabled,
|
|
268
269
|
onName,
|
|
269
270
|
onToggleAuth,
|
|
271
|
+
agentAvailable,
|
|
272
|
+
onToggleAgent,
|
|
270
273
|
onAllowlist,
|
|
271
274
|
onSave,
|
|
272
275
|
onCancel,
|
|
@@ -278,6 +281,8 @@ function Wizard({
|
|
|
278
281
|
saveDisabled: boolean;
|
|
279
282
|
onName: (name: string) => void;
|
|
280
283
|
onToggleAuth: () => void;
|
|
284
|
+
agentAvailable: boolean;
|
|
285
|
+
onToggleAgent: () => void;
|
|
281
286
|
onAllowlist: (logins: string[]) => void;
|
|
282
287
|
onSave: () => void;
|
|
283
288
|
onCancel: () => void;
|
|
@@ -329,6 +334,13 @@ function Wizard({
|
|
|
329
334
|
/>
|
|
330
335
|
</box>
|
|
331
336
|
) : null}
|
|
337
|
+
{agentAvailable ? (
|
|
338
|
+
<box style={{ flexDirection: "row", alignItems: "center", marginTop: 1 }}>
|
|
339
|
+
<text fg={focus === "agent" ? tokens.accent : tokens.textDim}>Allow agent messages</text>
|
|
340
|
+
<box style={{ flexGrow: 1 }} />
|
|
341
|
+
<Switch on={draft.agentEnabled ?? false} onToggle={onToggleAgent} theme={theme} />
|
|
342
|
+
</box>
|
|
343
|
+
) : null}
|
|
332
344
|
<box style={{ flexGrow: 1 }} />
|
|
333
345
|
<DialogActions
|
|
334
346
|
confirmLabel={draft.editingId ? "save" : "create"}
|
|
@@ -346,6 +358,7 @@ export interface ShareDialogProps {
|
|
|
346
358
|
threadName: string;
|
|
347
359
|
links: ShareLink[];
|
|
348
360
|
isOwner: boolean;
|
|
361
|
+
agentAvailable?: boolean;
|
|
349
362
|
onCreateLink: (input: NewShareLink) => void;
|
|
350
363
|
onUpdateLink: (id: string, input: NewShareLink) => void;
|
|
351
364
|
onDeleteLink: (id: string) => void;
|
|
@@ -359,6 +372,7 @@ export function ShareDialog({
|
|
|
359
372
|
threadName,
|
|
360
373
|
links,
|
|
361
374
|
isOwner,
|
|
375
|
+
agentAvailable = false,
|
|
362
376
|
onCreateLink,
|
|
363
377
|
onUpdateLink,
|
|
364
378
|
onDeleteLink,
|
|
@@ -384,8 +398,13 @@ export function ShareDialog({
|
|
|
384
398
|
shareDialogStore.getState().openWizard(draft);
|
|
385
399
|
};
|
|
386
400
|
|
|
387
|
-
const wizardRing = (): WizardFocus[] =>
|
|
388
|
-
|
|
401
|
+
const wizardRing = (): WizardFocus[] => [
|
|
402
|
+
"name",
|
|
403
|
+
"auth",
|
|
404
|
+
...(wizard?.requireAuth ? ["allowlist" as const] : []),
|
|
405
|
+
...(agentAvailable ? ["agent" as const] : []),
|
|
406
|
+
"actions",
|
|
407
|
+
];
|
|
389
408
|
|
|
390
409
|
const moveWizardFocus = (delta: number): void => {
|
|
391
410
|
const ring = wizardRing();
|
|
@@ -405,6 +424,7 @@ export function ShareDialog({
|
|
|
405
424
|
allowlist: wizard.allowlist,
|
|
406
425
|
};
|
|
407
426
|
|
|
427
|
+
if (agentAvailable) input.agentEnabled = wizard.agentEnabled ?? false;
|
|
408
428
|
if (wizard.editingId) onUpdateLink(wizard.editingId, input);
|
|
409
429
|
else onCreateLink(input);
|
|
410
430
|
backToList();
|
|
@@ -436,6 +456,8 @@ export function ShareDialog({
|
|
|
436
456
|
const activated = name === "return" || name === "enter";
|
|
437
457
|
|
|
438
458
|
if (wizardFocus === "auth" && (activated || name === "space")) return toggleAuth();
|
|
459
|
+
if (wizardFocus === "agent" && (activated || name === "space"))
|
|
460
|
+
return shareDialogStore.getState().setAgentEnabled(!wizard.agentEnabled);
|
|
439
461
|
if (wizardFocus === "actions" && activated) saveWizard();
|
|
440
462
|
};
|
|
441
463
|
|
|
@@ -494,6 +516,8 @@ export function ShareDialog({
|
|
|
494
516
|
>
|
|
495
517
|
{wizard ? (
|
|
496
518
|
<Wizard
|
|
519
|
+
agentAvailable={agentAvailable}
|
|
520
|
+
onToggleAgent={() => shareDialogStore.getState().setAgentEnabled(!wizard.agentEnabled)}
|
|
497
521
|
draft={wizard}
|
|
498
522
|
focus={wizardFocus}
|
|
499
523
|
saveDisabled={saveDisabled}
|
|
@@ -16,6 +16,7 @@ export interface ShareWizardDraft {
|
|
|
16
16
|
name: string;
|
|
17
17
|
requireAuth: boolean;
|
|
18
18
|
allowlist: string[];
|
|
19
|
+
agentEnabled?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface ShareDialogState {
|
|
@@ -27,6 +28,7 @@ export interface ShareDialogState {
|
|
|
27
28
|
setName: (name: string) => void;
|
|
28
29
|
setRequireAuth: (requireAuth: boolean) => void;
|
|
29
30
|
setAllowlist: (allowlist: string[]) => void;
|
|
31
|
+
setAgentEnabled: (agentEnabled: boolean) => void;
|
|
30
32
|
reset: () => void;
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -46,6 +48,8 @@ export const shareDialogStore = createStore<ShareDialogState>((set) => ({
|
|
|
46
48
|
set((state) => (state.wizard ? { wizard: { ...state.wizard, requireAuth } } : {})),
|
|
47
49
|
setAllowlist: (allowlist) =>
|
|
48
50
|
set((state) => (state.wizard ? { wizard: { ...state.wizard, allowlist } } : {})),
|
|
51
|
+
setAgentEnabled: (agentEnabled) =>
|
|
52
|
+
set((state) => (state.wizard ? { wizard: { ...state.wizard, agentEnabled } } : {})),
|
|
49
53
|
reset: () => set({ category: "external", wizard: null }),
|
|
50
54
|
}));
|
|
51
55
|
|
|
@@ -412,7 +412,7 @@ export function ThreadView({
|
|
|
412
412
|
blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
|
|
413
413
|
const measuredBox = (children: React.ReactNode): React.ReactNode => (
|
|
414
414
|
<box
|
|
415
|
-
key={`discussion-block-${blockIndex}`}
|
|
415
|
+
key={isPromptBlock?.(blockIndex) ? "prompt-block" : `discussion-block-${blockIndex}`}
|
|
416
416
|
id={`discussion-block-${blockIndex}`}
|
|
417
417
|
ref={virtual.measureRef(blockIndex)}
|
|
418
418
|
style={{ flexDirection: "column" }}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
SCHEMA_VERSION,
|
|
6
|
+
type Thread,
|
|
7
|
+
type ThreadAgentState,
|
|
8
|
+
type AgentPromptRequest,
|
|
9
|
+
} from "@cueloop/schema";
|
|
5
10
|
import { AgentThreadPane, AgentThreadPrototype } from "./AgentThreadPane";
|
|
6
11
|
import { ThreadFooter } from "./ThreadFooter";
|
|
7
12
|
import { ThreadView } from "../../markdown/components/ThreadView";
|
|
@@ -16,6 +21,7 @@ import {
|
|
|
16
21
|
waitForState,
|
|
17
22
|
} from "../../testing/test-support";
|
|
18
23
|
import type { ThreadAgentClient } from "../use-thread-agent";
|
|
24
|
+
import type { EventFrame } from "@cueloop/daemon/client";
|
|
19
25
|
|
|
20
26
|
const thread: Thread = {
|
|
21
27
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -39,8 +45,14 @@ const empty: ThreadAgentState = {
|
|
|
39
45
|
|
|
40
46
|
function createTestAgentClient(initial: ThreadAgentState) {
|
|
41
47
|
let state = initial;
|
|
42
|
-
const prompts:
|
|
48
|
+
const prompts: AgentPromptRequest[] = [];
|
|
49
|
+
const listeners = new Set<(event: EventFrame) => void>();
|
|
43
50
|
const client: ThreadAgentClient = {
|
|
51
|
+
onEvent: (listener) => {
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
|
|
54
|
+
return () => listeners.delete(listener);
|
|
55
|
+
},
|
|
44
56
|
agentGet: async () => state,
|
|
45
57
|
agentPrompt: async (params) => {
|
|
46
58
|
prompts.push(params);
|
|
@@ -66,7 +78,12 @@ function createTestAgentClient(initial: ThreadAgentState) {
|
|
|
66
78
|
agentPermission: async () => ({ ...state, phase: { kind: "idle" } }),
|
|
67
79
|
};
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
const publishTestAgentState = (next: ThreadAgentState): void => {
|
|
82
|
+
state = next;
|
|
83
|
+
for (const listener of listeners) listener({ event: "agent.updated", sessionId: thread.id });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return { client, prompts, publishTestAgentState };
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
function artifactView(
|
|
@@ -132,6 +149,7 @@ test("typing on the final blank line invokes the agent while the existing footer
|
|
|
132
149
|
setup.mockInput.pressKey("RETURN", { ctrl: true });
|
|
133
150
|
await waitForText(setup, "The timer survives cancellation.");
|
|
134
151
|
expect(prompts[0]?.text).toBe("Explain retries");
|
|
152
|
+
expect(prompts[0]?.inputOnly).toBe(true);
|
|
135
153
|
const send = locateText(setup, "Send message (0)");
|
|
136
154
|
|
|
137
155
|
await setup.mockMouse.click(send.column, send.row);
|
|
@@ -827,3 +845,91 @@ test("a failed mutation flush restores the prompt alongside a newer visible draf
|
|
|
827
845
|
setup.renderer.destroy();
|
|
828
846
|
}
|
|
829
847
|
});
|
|
848
|
+
|
|
849
|
+
test("an offline shared agent accepts continuation below its status without model controls", async () => {
|
|
850
|
+
const state: ThreadAgentState = {
|
|
851
|
+
...empty,
|
|
852
|
+
messages: [
|
|
853
|
+
{ id: "online-answer", role: "agent", text: "An online answer", complete: true, revision: 1 },
|
|
854
|
+
],
|
|
855
|
+
};
|
|
856
|
+
const { client, prompts, publishTestAgentState } = createTestAgentClient(state);
|
|
857
|
+
const setup = await testRender(
|
|
858
|
+
<AgentThreadPane
|
|
859
|
+
thread={thread}
|
|
860
|
+
client={client}
|
|
861
|
+
focused
|
|
862
|
+
theme={DARK}
|
|
863
|
+
onActiveChange={noop}
|
|
864
|
+
onOpenFile={noop}
|
|
865
|
+
>
|
|
866
|
+
{artifactView(thread)}
|
|
867
|
+
</AgentThreadPane>,
|
|
868
|
+
{ width: 80, height: 15, kittyKeyboard: true },
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
await waitForText(setup, "An online answer");
|
|
873
|
+
await waitForState(
|
|
874
|
+
setup,
|
|
875
|
+
() => setup.renderer.currentFocusedEditor !== null,
|
|
876
|
+
"online continuation editor",
|
|
877
|
+
);
|
|
878
|
+
publishTestAgentState({ ...state, phase: { kind: "offline" } });
|
|
879
|
+
await waitForText(setup, "Owner offline");
|
|
880
|
+
expect(setup.captureCharFrame()).toContain("Original artifact");
|
|
881
|
+
expect(setup.captureCharFrame()).not.toContain("Thinking");
|
|
882
|
+
const status = locateText(setup, "Owner offline");
|
|
883
|
+
|
|
884
|
+
await setup.mockMouse.click(status.column + 5, status.row + 3);
|
|
885
|
+
await waitForState(
|
|
886
|
+
setup,
|
|
887
|
+
() => setup.renderer.currentFocusedEditor !== null,
|
|
888
|
+
"offline continuation editor",
|
|
889
|
+
);
|
|
890
|
+
await typeText(setup, "Queue until the owner reconnects");
|
|
891
|
+
await waitForText(setup, "Queue until the owner reconnects");
|
|
892
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
893
|
+
expect(prompts[0]?.text).toBe("Queue until the owner reconnects");
|
|
894
|
+
expect(prompts[0]?.inputOnly).toBe(true);
|
|
895
|
+
} finally {
|
|
896
|
+
setup.renderer.destroy();
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("shared viewers see pending activity without owner permission controls", async () => {
|
|
901
|
+
const { client } = createTestAgentClient({
|
|
902
|
+
...empty,
|
|
903
|
+
phase: {
|
|
904
|
+
kind: "permission",
|
|
905
|
+
permission: {
|
|
906
|
+
id: "owner-permission",
|
|
907
|
+
title: "Owner approval required",
|
|
908
|
+
options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }],
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
client.canControlAgent = false;
|
|
914
|
+
const setup = await testRender(
|
|
915
|
+
<AgentThreadPane
|
|
916
|
+
thread={thread}
|
|
917
|
+
client={client}
|
|
918
|
+
focused
|
|
919
|
+
theme={DARK}
|
|
920
|
+
onActiveChange={noop}
|
|
921
|
+
onOpenFile={noop}
|
|
922
|
+
>
|
|
923
|
+
{artifactView(thread)}
|
|
924
|
+
</AgentThreadPane>,
|
|
925
|
+
{ width: 80, height: 15 },
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
try {
|
|
929
|
+
await waitForText(setup, "Thinking");
|
|
930
|
+
expect(setup.captureCharFrame()).not.toContain("Allow once");
|
|
931
|
+
expect(setup.captureCharFrame()).not.toContain("Owner approval required");
|
|
932
|
+
} finally {
|
|
933
|
+
setup.renderer.destroy();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
@@ -86,18 +86,20 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
86
86
|
const notifyState = useEffectEvent(() => props.onStateChange?.(state));
|
|
87
87
|
const notifyControls = useEffectEvent(() =>
|
|
88
88
|
props.onControlsChange?.(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
props.client && !props.client.agentConfigure ? null : (
|
|
90
|
+
<AgentConfigControls
|
|
91
|
+
state={state}
|
|
92
|
+
theme={theme}
|
|
93
|
+
onConfigure={(configId, value) =>
|
|
94
|
+
void agent.act((client) => {
|
|
95
|
+
if (!client.agentConfigure)
|
|
96
|
+
return Promise.reject(new Error("Agent configuration is unavailable"));
|
|
97
|
+
|
|
98
|
+
return client.agentConfigure({ id: thread.id, configId, value });
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
/>
|
|
102
|
+
),
|
|
101
103
|
),
|
|
102
104
|
);
|
|
103
105
|
|
|
@@ -135,7 +137,12 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
135
137
|
}
|
|
136
138
|
await props.flushMutations?.();
|
|
137
139
|
const accepted = await agent.act((client) =>
|
|
138
|
-
client.agentPrompt({
|
|
140
|
+
client.agentPrompt({
|
|
141
|
+
id: thread.id,
|
|
142
|
+
text: input.text,
|
|
143
|
+
inputOnly: Boolean(input.text.trim()),
|
|
144
|
+
operationId: newAnnotationId(),
|
|
145
|
+
}),
|
|
139
146
|
);
|
|
140
147
|
|
|
141
148
|
if (!accepted) rejected.push(input.text);
|
|
@@ -194,7 +201,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
194
201
|
);
|
|
195
202
|
|
|
196
203
|
if (!submission) return undefined;
|
|
197
|
-
if (submission.status === "failed")
|
|
204
|
+
if (submission.status === "failed" && props.client?.canControlAgent !== false)
|
|
198
205
|
return {
|
|
199
206
|
label: "Retry",
|
|
200
207
|
run: () =>
|
|
@@ -317,8 +324,11 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
317
324
|
renderBlock={(index) =>
|
|
318
325
|
index === projection.activityIndex ? (
|
|
319
326
|
<box style={{ flexDirection: "column", paddingLeft: 2 }}>
|
|
327
|
+
{state.phase.kind === "offline" ? (
|
|
328
|
+
<text fg={theme.textMuted}>Owner offline</text>
|
|
329
|
+
) : null}
|
|
320
330
|
{busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
|
|
321
|
-
{state.phase.kind === "permission" ? (
|
|
331
|
+
{state.phase.kind === "permission" && props.client?.canControlAgent !== false ? (
|
|
322
332
|
<>
|
|
323
333
|
<text fg={theme.text}>{state.phase.permission.title}</text>
|
|
324
334
|
<box style={{ flexDirection: "row", gap: 2 }}>
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type Artifact,
|
|
40
40
|
type DiffFileContents,
|
|
41
41
|
type ShareLink,
|
|
42
|
+
type SharePolicy,
|
|
42
43
|
type Thread,
|
|
43
44
|
type SessionHistory,
|
|
44
45
|
type MessageOutcome,
|
|
@@ -241,6 +242,7 @@ export interface NewShareLink {
|
|
|
241
242
|
name?: string;
|
|
242
243
|
requireAuth: boolean;
|
|
243
244
|
allowlist: string[];
|
|
245
|
+
agentEnabled?: boolean;
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
export interface ReviewControllerOptions {
|
|
@@ -1697,15 +1699,16 @@ class Controller implements ReviewController {
|
|
|
1697
1699
|
this.setStatus("sharing…");
|
|
1698
1700
|
this.freezeForShare(session)
|
|
1699
1701
|
.then((shared) => {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1702
|
+
const policy: ShareLink = {
|
|
1703
|
+
id: session.id,
|
|
1704
|
+
requireAuth: input.requireAuth,
|
|
1705
|
+
allowlist: input.allowlist,
|
|
1706
|
+
shareBranch,
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
|
|
1710
|
+
|
|
1711
|
+
return this.shareTransport.publish(shared, {}, policy);
|
|
1709
1712
|
})
|
|
1710
1713
|
.then(async ({ line, copied }) => {
|
|
1711
1714
|
const id = this.shareTransport.parseShareId(line);
|
|
@@ -1719,6 +1722,7 @@ class Controller implements ReviewController {
|
|
|
1719
1722
|
shareBranch,
|
|
1720
1723
|
};
|
|
1721
1724
|
|
|
1725
|
+
if (input.agentEnabled !== undefined) link.agentEnabled = input.agentEnabled;
|
|
1722
1726
|
await client.sessionSetShares(session.id, [...this.linksFor(this.snapshot.session), link]);
|
|
1723
1727
|
this.setStatus("");
|
|
1724
1728
|
this.showToast(line, copied ? "link copied" : "link created");
|
|
@@ -1738,20 +1742,24 @@ class Controller implements ReviewController {
|
|
|
1738
1742
|
if (!links.some((link) => link.id === id)) return;
|
|
1739
1743
|
const allowlist = input.requireAuth ? input.allowlist : [];
|
|
1740
1744
|
|
|
1745
|
+
const policy: SharePolicy = { requireAuth: input.requireAuth, allowlist };
|
|
1746
|
+
|
|
1747
|
+
if (input.agentEnabled !== undefined) policy.agentEnabled = input.agentEnabled;
|
|
1741
1748
|
// Publish the same policy stored on the local link.
|
|
1742
|
-
void this.shareTransport
|
|
1743
|
-
|
|
1744
|
-
.
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1749
|
+
void this.shareTransport.push(id, [], policy).catch(() => {});
|
|
1750
|
+
const next = links.map((link) => {
|
|
1751
|
+
if (link.id !== id) return link;
|
|
1752
|
+
const updated = {
|
|
1753
|
+
...link,
|
|
1754
|
+
name: input.name?.trim() || undefined,
|
|
1755
|
+
requireAuth: input.requireAuth,
|
|
1756
|
+
allowlist,
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
if (input.agentEnabled !== undefined) updated.agentEnabled = input.agentEnabled;
|
|
1760
|
+
|
|
1761
|
+
return updated;
|
|
1762
|
+
});
|
|
1755
1763
|
|
|
1756
1764
|
this.applyOptimistic(
|
|
1757
1765
|
{ ...session, shares: next },
|
|
@@ -8,7 +8,7 @@ export type ThreadAgentClient = Pick<
|
|
|
8
8
|
DaemonClient,
|
|
9
9
|
"agentGet" | "agentPrompt" | "agentCancel" | "agentComment" | "agentPermission"
|
|
10
10
|
> &
|
|
11
|
-
Partial<Pick<DaemonClient, "agentConfigure"
|
|
11
|
+
Partial<Pick<DaemonClient, "agentConfigure" | "onEvent">> & { canControlAgent?: boolean };
|
|
12
12
|
|
|
13
13
|
/** Subscribe to durable agent state; unmount closes only this client connection. */
|
|
14
14
|
export function useThreadAgent(id: string, home?: string, injected?: ThreadAgentClient) {
|
|
@@ -48,6 +48,12 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
|
|
|
48
48
|
void (async () => {
|
|
49
49
|
try {
|
|
50
50
|
if (injected) {
|
|
51
|
+
unsubscribe = injected.onEvent?.((event) => {
|
|
52
|
+
if (event.event === "agent.updated" && event.sessionId === id)
|
|
53
|
+
void refresh(injected).catch((error) => {
|
|
54
|
+
if (!cancelled) setError(String(error));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
51
57
|
await refresh(injected);
|
|
52
58
|
await configure(injected);
|
|
53
59
|
|