@cueloop/client 0.1.0-alpha.97 → 0.1.0-alpha.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +31 -8
- package/src/annotations/use-annotation-surface.tsx +257 -31
- package/src/app/App.deleted-scroll.test.tsx +1 -0
- package/src/app/App.diff.test.tsx +1 -0
- package/src/app/App.identity.test.tsx +1 -0
- package/src/app/App.regression.test.tsx +5 -0
- package/src/app/App.share-dialog.test.tsx +5 -0
- package/src/app/App.submit-confirm.test.tsx +3 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +223 -97
- package/src/app/App.visual.test.tsx +2 -0
- package/src/app/App.workbench.test.tsx +53 -0
- package/src/app/app-screens.tsx +3 -0
- package/src/app/components/AppShell.tsx +1 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/run.ts +1 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
- package/src/diff/components/AnnotatableFileView.tsx +8 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +10 -0
- package/src/diff/diff-intraline.test.ts +1 -0
- package/src/diff/diff-intraline.ts +2 -0
- package/src/diff/split-diff.ts +2 -0
- package/src/integrations/components/ShareDialog.tsx +1 -0
- package/src/markdown/components/ThreadView.tsx +62 -16
- package/src/markdown/mark-runs.ts +1 -0
- package/src/markdown/markdown-highlight.ts +2 -0
- package/src/performance/otlp-trace.test.ts +3 -0
- package/src/performance/otlp-trace.ts +4 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/settings/components/quick-actions-editor.test.tsx +3 -0
- 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/stories/stories-app.tsx +3 -0
- package/src/stories/story.ts +2 -0
- package/src/terminal/pty.ts +1 -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 +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -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 +829 -0
- package/src/thread/components/AgentThreadPane.tsx +367 -0
- package/src/thread/components/ThreadFooter.test.tsx +2 -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/components/thread-tree.test.ts +3 -0
- package/src/thread/components/thread-tree.ts +4 -0
- package/src/thread/thread-controller.curate.test.ts +1 -0
- package/src/thread/thread-controller.live-diff.test.ts +7 -0
- package/src/thread/thread-controller.share.test.ts +1 -0
- package/src/thread/thread-controller.ts +42 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +111 -0
- package/src/ui/components/PromptDialog.test.tsx +4 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/primitives/Dialog.test.tsx +1 -0
- package/src/workbench/components/ChangesColumn.tsx +1 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +7 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +1 -0
- package/src/workbench/components/editor-grid.test.ts +11 -0
- package/src/workbench/components/editor-grid.ts +10 -0
- package/src/workbench/use-changes-workbench.ts +5 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type Thread, type ThreadAgentState } from "@cueloop/schema";
|
|
3
|
+
import {
|
|
4
|
+
projectAgentTranscript,
|
|
5
|
+
projectThreadConversation,
|
|
6
|
+
commentOnAgentSpan,
|
|
7
|
+
agentToolFilePath,
|
|
8
|
+
} from "./agent-transcript";
|
|
9
|
+
|
|
10
|
+
import { buildDisplay, marksByDisplay } from "../markdown/view-plan";
|
|
11
|
+
|
|
12
|
+
const thread: Thread = {
|
|
13
|
+
schemaVersion: SCHEMA_VERSION,
|
|
14
|
+
id: "thread",
|
|
15
|
+
workspace: { repoRoot: "/tmp", branch: "main" },
|
|
16
|
+
artifact: { type: "plan", content: "Original artifact", meta: {} },
|
|
17
|
+
annotations: [],
|
|
18
|
+
revisions: [],
|
|
19
|
+
status: "pending",
|
|
20
|
+
message: null,
|
|
21
|
+
createdAt: "2026-10-04",
|
|
22
|
+
};
|
|
23
|
+
const state: ThreadAgentState = {
|
|
24
|
+
threadId: "thread",
|
|
25
|
+
phase: { kind: "idle" },
|
|
26
|
+
tools: [],
|
|
27
|
+
comments: [],
|
|
28
|
+
messages: [
|
|
29
|
+
{ id: "question", role: "user", text: "Why?", complete: true, revision: 1 },
|
|
30
|
+
{ id: "answer-1", role: "agent", text: "Same quote.", complete: true, revision: 1 },
|
|
31
|
+
{ id: "answer-2", role: "agent", text: "Same quote.", complete: true, revision: 1 },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
test("repeated quotes bind to the chosen answer instead of an earlier message", () => {
|
|
36
|
+
const comment = commentOnAgentSpan(
|
|
37
|
+
thread,
|
|
38
|
+
state,
|
|
39
|
+
{ start: { blockIndex: 5, char: 0 }, end: { blockIndex: 5, char: 4 } },
|
|
40
|
+
"Explain",
|
|
41
|
+
"note",
|
|
42
|
+
);
|
|
43
|
+
const projected = projectAgentTranscript(thread, { ...state, comments: [comment] });
|
|
44
|
+
|
|
45
|
+
expect(comment.messageId).toBe("answer-2");
|
|
46
|
+
expect(comment.anchor.quote).toBe("Same");
|
|
47
|
+
expect(projected.marks.has(5)).toBe(true);
|
|
48
|
+
expect(projected.marks.has(3)).toBe(false);
|
|
49
|
+
expect(thread.artifact.content).toBe("Original artifact");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a question, a cross-message selection, or a changing answer cannot accept feedback", () => {
|
|
53
|
+
const span = { start: { blockIndex: 1, char: 0 }, end: { blockIndex: 1, char: 3 } };
|
|
54
|
+
|
|
55
|
+
expect(() => commentOnAgentSpan(thread, state, span, "note", "id")).toThrow();
|
|
56
|
+
expect(() =>
|
|
57
|
+
commentOnAgentSpan(
|
|
58
|
+
thread,
|
|
59
|
+
state,
|
|
60
|
+
{ ...span, start: { blockIndex: 3, char: 0 }, end: { blockIndex: 5, char: 4 } },
|
|
61
|
+
"note",
|
|
62
|
+
"id",
|
|
63
|
+
),
|
|
64
|
+
).toThrow();
|
|
65
|
+
expect(() =>
|
|
66
|
+
commentOnAgentSpan(
|
|
67
|
+
thread,
|
|
68
|
+
{ ...state, messages: state.messages.map((message) => ({ ...message, complete: false })) },
|
|
69
|
+
{ ...span, start: { blockIndex: 3, char: 0 }, end: { blockIndex: 3, char: 4 } },
|
|
70
|
+
"note",
|
|
71
|
+
"id",
|
|
72
|
+
),
|
|
73
|
+
).toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("tool locations open the correct repository file from a subdirectory cwd", () => {
|
|
77
|
+
const nested: Thread = {
|
|
78
|
+
...thread,
|
|
79
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
80
|
+
artifact: { ...thread.artifact, meta: { cwd: "/repo/packages/client" } },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
expect(agentToolFilePath(nested, "/repo/packages/client/src/App.tsx")).toBe(
|
|
84
|
+
"packages/client/src/App.tsx",
|
|
85
|
+
);
|
|
86
|
+
expect(agentToolFilePath(nested, "src/App.tsx")).toBe("packages/client/src/App.tsx");
|
|
87
|
+
expect(agentToolFilePath(nested, "../../README.md")).toBe("README.md");
|
|
88
|
+
expect(agentToolFilePath(nested, "/outside/README.md")).toBeUndefined();
|
|
89
|
+
expect(agentToolFilePath(nested, "../../../outside.ts")).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("editable artifact and agent comments stay inline until accepted for invocation", () => {
|
|
93
|
+
const comment = commentOnAgentSpan(
|
|
94
|
+
thread,
|
|
95
|
+
state,
|
|
96
|
+
{ start: { blockIndex: 3, char: 0 }, end: { blockIndex: 3, char: 4 } },
|
|
97
|
+
"Explain the answer",
|
|
98
|
+
"agent-note",
|
|
99
|
+
);
|
|
100
|
+
const artifactNote = {
|
|
101
|
+
id: "artifact-note",
|
|
102
|
+
kind: "comment" as const,
|
|
103
|
+
body: "Explain the artifact",
|
|
104
|
+
createdAt: thread.createdAt,
|
|
105
|
+
anchor: { quote: "Original", prefix: "", suffix: " artifact", blockIndex: 0, start: 0, end: 8 },
|
|
106
|
+
};
|
|
107
|
+
const annotated = { ...thread, annotations: [artifactNote] };
|
|
108
|
+
const pending = { ...state, comments: [comment] };
|
|
109
|
+
const display = buildDisplay(thread.artifact.content);
|
|
110
|
+
const marks = marksByDisplay(annotated.annotations, display);
|
|
111
|
+
const inline = projectThreadConversation(annotated, pending, display, marks);
|
|
112
|
+
|
|
113
|
+
expect(inline.mirrors.size).toBe(0);
|
|
114
|
+
expect(inline.session.annotations.some((entry) => entry.id === artifactNote.id)).toBe(true);
|
|
115
|
+
expect(inline.session.annotations.some((entry) => entry.id === comment.id)).toBe(true);
|
|
116
|
+
const submitted = projectThreadConversation(
|
|
117
|
+
annotated,
|
|
118
|
+
{
|
|
119
|
+
...pending,
|
|
120
|
+
submissions: [
|
|
121
|
+
{
|
|
122
|
+
id: "accepted",
|
|
123
|
+
commentId: artifactNote.id,
|
|
124
|
+
prompt: artifactNote.body,
|
|
125
|
+
quote: artifactNote.anchor.quote,
|
|
126
|
+
status: "queued",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
display,
|
|
131
|
+
marks,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(submitted.mirrors.size).toBe(1);
|
|
135
|
+
expect(submitted.mirrors.get("mirror:accepted")?.commentId).toBe(artifactNote.id);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test.each([undefined, "plain-prompt"])(
|
|
139
|
+
"accepted bottom prompts remain user messages without synthetic comment mirrors (%s)",
|
|
140
|
+
(commentId) => {
|
|
141
|
+
const emptyThread = { ...thread, artifact: { ...thread.artifact, content: "" } };
|
|
142
|
+
const projected = projectThreadConversation(
|
|
143
|
+
emptyThread,
|
|
144
|
+
{
|
|
145
|
+
...state,
|
|
146
|
+
messages: [],
|
|
147
|
+
submissions: [
|
|
148
|
+
{ id: "plain-prompt", commentId, prompt: "Hello from the prompt", status: "completed" },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
[],
|
|
152
|
+
new Map(),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(projected.mirrors.size).toBe(0);
|
|
156
|
+
expect(projected.session.annotations).toEqual([]);
|
|
157
|
+
expect(projected.display.some((block) => block.work?.text === "Hello from the prompt")).toBe(
|
|
158
|
+
true,
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
makeAnchor,
|
|
4
|
+
parseBlocks,
|
|
5
|
+
type AgentComment,
|
|
6
|
+
type Thread,
|
|
7
|
+
type ThreadAgentState,
|
|
8
|
+
} from "@cueloop/schema";
|
|
9
|
+
import {
|
|
10
|
+
buildDisplay,
|
|
11
|
+
marksByDisplay,
|
|
12
|
+
renderedSpanToWork,
|
|
13
|
+
type DisplayBlock,
|
|
14
|
+
type Mark,
|
|
15
|
+
} from "../markdown/view-plan";
|
|
16
|
+
import type { TextSpan } from "../annotations/thread-selection";
|
|
17
|
+
|
|
18
|
+
/** One parsed agent transcript owns both display blocks and message selection coordinates. */
|
|
19
|
+
export function projectAgentTranscript(thread: Thread, state: ThreadAgentState) {
|
|
20
|
+
const display: DisplayBlock[] = [];
|
|
21
|
+
const offsets = new Map<string, number>();
|
|
22
|
+
const sources: { messageId: string; blockIndex: number }[] = [];
|
|
23
|
+
const annotations = state.comments.map((comment) => ({
|
|
24
|
+
...comment,
|
|
25
|
+
kind: "comment",
|
|
26
|
+
createdAt: thread.createdAt,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
for (const message of state.messages) {
|
|
30
|
+
display.push(
|
|
31
|
+
...buildDisplay(`## ${message.role === "user" ? "You" : (state.harness?.label ?? "Agent")}`),
|
|
32
|
+
);
|
|
33
|
+
sources.push({ messageId: "", blockIndex: -1 });
|
|
34
|
+
offsets.set(message.id, display.length);
|
|
35
|
+
const blocks = buildDisplay(message.text);
|
|
36
|
+
|
|
37
|
+
display.push(...blocks);
|
|
38
|
+
sources.push(...blocks.map((_, blockIndex) => ({ messageId: message.id, blockIndex })));
|
|
39
|
+
if (message.role === "user") {
|
|
40
|
+
const tools = state.tools.filter((tool) => tool.turnId === message.id);
|
|
41
|
+
|
|
42
|
+
if (tools.length) {
|
|
43
|
+
display.push(
|
|
44
|
+
...buildDisplay(
|
|
45
|
+
`${tools.length} tool ${tools.length === 1 ? "call" : "calls"} · ${tools.filter((tool) => tool.kind === "read").length} reads · ${tools.filter((tool) => tool.kind === "execute").length} commands`,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
sources.push({ messageId: "", blockIndex: -1 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const marks = new Map<number, Mark[]>();
|
|
53
|
+
|
|
54
|
+
for (const message of state.messages) {
|
|
55
|
+
const local = annotations.filter((annotation) => annotation.messageId === message.id);
|
|
56
|
+
const offset = offsets.get(message.id)!;
|
|
57
|
+
|
|
58
|
+
for (const [index, values] of marksByDisplay(local, buildDisplay(message.text)))
|
|
59
|
+
marks.set(
|
|
60
|
+
index + offset,
|
|
61
|
+
values.map((mark) => ({
|
|
62
|
+
...mark,
|
|
63
|
+
span: mark.span
|
|
64
|
+
? {
|
|
65
|
+
start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + offset },
|
|
66
|
+
end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + offset },
|
|
67
|
+
}
|
|
68
|
+
: undefined,
|
|
69
|
+
})),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const session: Thread = {
|
|
73
|
+
...thread,
|
|
74
|
+
history: undefined,
|
|
75
|
+
workingCopy: undefined,
|
|
76
|
+
textCuts: undefined,
|
|
77
|
+
artifact: { type: "plan", content: "", meta: {} },
|
|
78
|
+
annotations,
|
|
79
|
+
status: "pending",
|
|
80
|
+
message: null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return { display, marks, session, sources };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Agent feedback cannot span messages or bind to an unfinished answer. */
|
|
87
|
+
export function commentOnAgentSpan(
|
|
88
|
+
thread: Thread,
|
|
89
|
+
state: ThreadAgentState,
|
|
90
|
+
span: TextSpan,
|
|
91
|
+
body: string,
|
|
92
|
+
id: string,
|
|
93
|
+
): AgentComment {
|
|
94
|
+
const projection = projectAgentTranscript(thread, state);
|
|
95
|
+
const start = projection.sources[span.start.blockIndex];
|
|
96
|
+
const end = projection.sources[span.end.blockIndex];
|
|
97
|
+
const message = state.messages.find((message) => message.id === start?.messageId);
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
!message ||
|
|
101
|
+
!start ||
|
|
102
|
+
!end ||
|
|
103
|
+
start.messageId !== end.messageId ||
|
|
104
|
+
message.role !== "agent" ||
|
|
105
|
+
!message.complete
|
|
106
|
+
) {
|
|
107
|
+
throw new Error("Agent comment requires a selection within one completed answer");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const range = renderedSpanToWork(
|
|
111
|
+
buildDisplay(message.text),
|
|
112
|
+
start.blockIndex,
|
|
113
|
+
end.blockIndex,
|
|
114
|
+
span.start.char,
|
|
115
|
+
span.end.char,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
messageId: message.id,
|
|
121
|
+
anchor: makeAnchor(
|
|
122
|
+
parseBlocks(message.text),
|
|
123
|
+
start.blockIndex,
|
|
124
|
+
range.start,
|
|
125
|
+
range.end,
|
|
126
|
+
end.blockIndex,
|
|
127
|
+
),
|
|
128
|
+
body,
|
|
129
|
+
sent: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Tool locations use the agent cwd; the file reader uses the repository root. */
|
|
134
|
+
export function agentToolFilePath(thread: Thread, path: string): string | undefined {
|
|
135
|
+
const root = thread.workspace.repoRoot;
|
|
136
|
+
const absolute = resolve(thread.artifact.meta.cwd ?? root, path);
|
|
137
|
+
const local = relative(root, absolute);
|
|
138
|
+
|
|
139
|
+
if (!local || local === ".." || local.startsWith("../") || isAbsolute(local)) return undefined;
|
|
140
|
+
|
|
141
|
+
return local;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The artifact, mirrored discussions, answers, and tail share one navigation projection. */
|
|
145
|
+
export function projectThreadConversation(
|
|
146
|
+
thread: Thread,
|
|
147
|
+
state: ThreadAgentState,
|
|
148
|
+
artifactDisplay: DisplayBlock[],
|
|
149
|
+
artifactMarks: Map<number, Mark[]>,
|
|
150
|
+
showActivity = state.phase.kind !== "idle",
|
|
151
|
+
) {
|
|
152
|
+
const display = [...artifactDisplay];
|
|
153
|
+
const marks = new Map(artifactMarks);
|
|
154
|
+
const annotations = [...thread.annotations];
|
|
155
|
+
const sources: (
|
|
156
|
+
| { kind: "artifact" }
|
|
157
|
+
| { kind: "message"; messageId: string; blockIndex: number }
|
|
158
|
+
| { kind: "mirror"; commentId?: string }
|
|
159
|
+
| { kind: "tail" }
|
|
160
|
+
| { kind: "activity" }
|
|
161
|
+
)[] = artifactDisplay.map(() => ({ kind: "artifact" }));
|
|
162
|
+
const mirrors = new Map<string, { commentId?: string; submissionId?: string }>();
|
|
163
|
+
const destinations = new Map<string, number>();
|
|
164
|
+
const mirror = (
|
|
165
|
+
id: string,
|
|
166
|
+
body: string,
|
|
167
|
+
quote: string,
|
|
168
|
+
commentId?: string,
|
|
169
|
+
submissionId?: string,
|
|
170
|
+
): void => {
|
|
171
|
+
const blocks: DisplayBlock[] = buildDisplay(quote);
|
|
172
|
+
|
|
173
|
+
if (!blocks.length)
|
|
174
|
+
blocks.push({
|
|
175
|
+
type: "same",
|
|
176
|
+
kind: "p",
|
|
177
|
+
work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
|
|
178
|
+
});
|
|
179
|
+
const offset = display.length;
|
|
180
|
+
const annotation = {
|
|
181
|
+
id: `mirror:${id}`,
|
|
182
|
+
kind: "comment",
|
|
183
|
+
body,
|
|
184
|
+
createdAt: thread.createdAt,
|
|
185
|
+
anchor: {
|
|
186
|
+
quote,
|
|
187
|
+
prefix: "",
|
|
188
|
+
suffix: "",
|
|
189
|
+
blockIndex: 0,
|
|
190
|
+
start: 0,
|
|
191
|
+
end: (blocks[0]?.work ?? blocks[0]?.base)?.text.length ?? 0,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
display.push(...blocks);
|
|
196
|
+
sources.push(...blocks.map(() => ({ kind: "mirror" as const, commentId })));
|
|
197
|
+
annotations.push(annotation);
|
|
198
|
+
if (commentId) {
|
|
199
|
+
const replies = [...thread.annotations, ...state.comments].filter(
|
|
200
|
+
(entry) => entry.replyTo === commentId,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
for (const reply of replies) {
|
|
204
|
+
const replica = {
|
|
205
|
+
...annotation,
|
|
206
|
+
id: `${annotation.id}:${reply.id}`,
|
|
207
|
+
body: reply.body,
|
|
208
|
+
author: reply.author,
|
|
209
|
+
replyTo: annotation.id,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
annotations.push(replica);
|
|
213
|
+
mirrors.set(replica.id, { commentId: reply.id });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const localMarks = quote.trim()
|
|
217
|
+
? marksByDisplay([annotation], blocks)
|
|
218
|
+
: new Map<number, Mark[]>([
|
|
219
|
+
[
|
|
220
|
+
0,
|
|
221
|
+
[
|
|
222
|
+
{
|
|
223
|
+
start: 0,
|
|
224
|
+
end: 0,
|
|
225
|
+
role: "mark-comment",
|
|
226
|
+
annotationId: annotation.id,
|
|
227
|
+
span: { start: { blockIndex: 0, char: 0 }, end: { blockIndex: 0, char: 0 } },
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
],
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
for (const [index, values] of localMarks) {
|
|
234
|
+
marks.set(
|
|
235
|
+
index + offset,
|
|
236
|
+
values.map((mark) => ({
|
|
237
|
+
...mark,
|
|
238
|
+
span: mark.span
|
|
239
|
+
? {
|
|
240
|
+
start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + offset },
|
|
241
|
+
end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + offset },
|
|
242
|
+
}
|
|
243
|
+
: undefined,
|
|
244
|
+
})),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
mirrors.set(annotation.id, { commentId, submissionId });
|
|
248
|
+
if (submissionId) destinations.set(submissionId, offset);
|
|
249
|
+
};
|
|
250
|
+
const message = (entry: ThreadAgentState["messages"][number]): void => {
|
|
251
|
+
const projection = projectAgentTranscript(thread, {
|
|
252
|
+
...state,
|
|
253
|
+
messages: [entry],
|
|
254
|
+
tools: [],
|
|
255
|
+
comments: state.comments.filter((comment) => comment.messageId === entry.id),
|
|
256
|
+
});
|
|
257
|
+
const offset = display.length;
|
|
258
|
+
|
|
259
|
+
display.push(...projection.display.slice(1));
|
|
260
|
+
sources.push(
|
|
261
|
+
...projection.sources.slice(1).map((source) => ({ kind: "message" as const, ...source })),
|
|
262
|
+
);
|
|
263
|
+
annotations.push(...projection.session.annotations);
|
|
264
|
+
for (const [index, values] of projection.marks) {
|
|
265
|
+
marks.set(
|
|
266
|
+
index - 1 + offset,
|
|
267
|
+
values.map((mark) => ({
|
|
268
|
+
...mark,
|
|
269
|
+
span: mark.span
|
|
270
|
+
? {
|
|
271
|
+
start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + offset - 1 },
|
|
272
|
+
end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + offset - 1 },
|
|
273
|
+
}
|
|
274
|
+
: undefined,
|
|
275
|
+
})),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (state.submissions?.length) {
|
|
281
|
+
for (const submission of state.submissions) {
|
|
282
|
+
// A prompt may use its submission ID as a discussion root; that is not an inline comment.
|
|
283
|
+
if (submission.commentId && submission.commentId !== submission.id) {
|
|
284
|
+
mirror(
|
|
285
|
+
submission.id,
|
|
286
|
+
submission.prompt,
|
|
287
|
+
submission.quote ?? " ",
|
|
288
|
+
submission.commentId,
|
|
289
|
+
submission.id,
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
destinations.set(submission.id, display.length);
|
|
293
|
+
message({
|
|
294
|
+
id: submission.id,
|
|
295
|
+
role: "user",
|
|
296
|
+
text: submission.prompt,
|
|
297
|
+
complete: true,
|
|
298
|
+
revision: 1,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
for (const entry of state.messages.filter(
|
|
302
|
+
(entry) => entry.role === "agent" && entry.submissionId === submission.id,
|
|
303
|
+
))
|
|
304
|
+
message(entry);
|
|
305
|
+
}
|
|
306
|
+
} else for (const entry of state.messages) message(entry);
|
|
307
|
+
const activityIndex = showActivity ? display.length : -1;
|
|
308
|
+
|
|
309
|
+
if (showActivity) {
|
|
310
|
+
display.push({
|
|
311
|
+
type: "same",
|
|
312
|
+
kind: "p",
|
|
313
|
+
work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
|
|
314
|
+
});
|
|
315
|
+
sources.push({ kind: "activity" });
|
|
316
|
+
}
|
|
317
|
+
const tailIndex = display.length;
|
|
318
|
+
|
|
319
|
+
display.push({
|
|
320
|
+
type: "same",
|
|
321
|
+
kind: "p",
|
|
322
|
+
work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
|
|
323
|
+
});
|
|
324
|
+
sources.push({ kind: "tail" });
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
display,
|
|
328
|
+
marks,
|
|
329
|
+
sources,
|
|
330
|
+
mirrors,
|
|
331
|
+
destinations,
|
|
332
|
+
activityIndex,
|
|
333
|
+
tailIndex,
|
|
334
|
+
session: { ...thread, annotations },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../appearance/theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
4
|
+
import { AgentConfigControls } from "./AgentConfigControls";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Chrome/AgentConfigControls" };
|
|
7
|
+
|
|
8
|
+
export const Choices: Story = {
|
|
9
|
+
size: { width: 40, height: 2 },
|
|
10
|
+
expectedColors: [DARK.textMuted],
|
|
11
|
+
render: () => (
|
|
12
|
+
<AgentConfigControls
|
|
13
|
+
theme={DARK}
|
|
14
|
+
onConfigure={() => {}}
|
|
15
|
+
state={{
|
|
16
|
+
threadId: "thread",
|
|
17
|
+
phase: { kind: "idle" },
|
|
18
|
+
messages: [],
|
|
19
|
+
tools: [],
|
|
20
|
+
comments: [],
|
|
21
|
+
configOptions: [
|
|
22
|
+
{
|
|
23
|
+
id: "model",
|
|
24
|
+
name: "Model",
|
|
25
|
+
currentValue: "test",
|
|
26
|
+
options: [{ value: "test", name: "Test model" }],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "effort",
|
|
30
|
+
name: "Reasoning",
|
|
31
|
+
category: "thought_level",
|
|
32
|
+
currentValue: "medium",
|
|
33
|
+
options: [{ value: "medium", name: "Medium" }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { DARK } from "../../appearance/theme";
|
|
5
|
+
import { RootOverlayProvider } from "../../ui/components/RootOverlay";
|
|
6
|
+
import { MenuControlProvider, useMenuControlState } from "../../ui/components/menu-control";
|
|
7
|
+
import { locateText, waitForText, settle } from "../../testing/test-support";
|
|
8
|
+
import { AgentConfigControls } from "./AgentConfigControls";
|
|
9
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
10
|
+
|
|
11
|
+
const initial: ThreadAgentState = {
|
|
12
|
+
threadId: "test",
|
|
13
|
+
phase: { kind: "idle" },
|
|
14
|
+
comments: [],
|
|
15
|
+
messages: [],
|
|
16
|
+
tools: [],
|
|
17
|
+
configOptions: [
|
|
18
|
+
{
|
|
19
|
+
id: "model",
|
|
20
|
+
name: "Model",
|
|
21
|
+
currentValue: "first",
|
|
22
|
+
options: [
|
|
23
|
+
{ value: "first", name: "First model" },
|
|
24
|
+
{ value: "second", name: "Second model" },
|
|
25
|
+
...Array.from({ length: 18 }, (_, index) => ({
|
|
26
|
+
value: `extra-${index}`,
|
|
27
|
+
name: `Extra model ${index}`,
|
|
28
|
+
})),
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "effort",
|
|
33
|
+
name: "Reasoning",
|
|
34
|
+
category: "thought_level",
|
|
35
|
+
currentValue: "medium",
|
|
36
|
+
options: [
|
|
37
|
+
{ value: "medium", name: "Medium" },
|
|
38
|
+
{ value: "high", name: "High" },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("model and reasoning choices share the existing overlay and close after selection", async () => {
|
|
45
|
+
const choices: string[] = [];
|
|
46
|
+
|
|
47
|
+
function TestMenus(): React.ReactNode {
|
|
48
|
+
const menu = useMenuControlState();
|
|
49
|
+
const [state, setState] = useState(initial);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<MenuControlProvider value={menu}>
|
|
53
|
+
<RootOverlayProvider>
|
|
54
|
+
<box style={{ height: 20, flexDirection: "column" }}>
|
|
55
|
+
<box style={{ flexGrow: 1 }}>
|
|
56
|
+
<text>Thread body</text>
|
|
57
|
+
</box>
|
|
58
|
+
<AgentConfigControls
|
|
59
|
+
state={state}
|
|
60
|
+
theme={DARK}
|
|
61
|
+
onConfigure={(id, value) => {
|
|
62
|
+
choices.push(`${id}:${value}`);
|
|
63
|
+
setState({
|
|
64
|
+
...state,
|
|
65
|
+
configOptions: state.configOptions?.map((option) =>
|
|
66
|
+
option.id === id ? { ...option, currentValue: value } : option,
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
</box>
|
|
72
|
+
</RootOverlayProvider>
|
|
73
|
+
</MenuControlProvider>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const setup = await testRender(<TestMenus />, { width: 80, height: 20 });
|
|
77
|
+
const click = async (text: string) => {
|
|
78
|
+
const point = locateText(setup, text);
|
|
79
|
+
|
|
80
|
+
await setup.mockMouse.click(point.column, point.row);
|
|
81
|
+
await settle(setup);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await waitForText(setup, "First model");
|
|
86
|
+
await click("First model");
|
|
87
|
+
await waitForText(setup, "Second model");
|
|
88
|
+
await click("Second model");
|
|
89
|
+
expect(choices).toEqual(["model:second"]);
|
|
90
|
+
expect(setup.captureCharFrame()).not.toContain("First model");
|
|
91
|
+
await click("Medium");
|
|
92
|
+
await waitForText(setup, "High");
|
|
93
|
+
await click("High");
|
|
94
|
+
expect(choices).toEqual(["model:second", "effort:high"]);
|
|
95
|
+
expect(setup.captureCharFrame()).not.toContain("Medium");
|
|
96
|
+
await click("Second model");
|
|
97
|
+
expect(setup.captureCharFrame()).not.toContain("Extra model 17");
|
|
98
|
+
const model = locateText(setup, "First model");
|
|
99
|
+
|
|
100
|
+
for (let count = 0; count < 12; count++)
|
|
101
|
+
await setup.mockMouse.scroll(model.column, model.row, "down");
|
|
102
|
+
await settle(setup);
|
|
103
|
+
await waitForText(setup, "Extra model 17");
|
|
104
|
+
await click("Extra model 17");
|
|
105
|
+
expect(choices.at(-1)).toBe("model:extra-17");
|
|
106
|
+
await click("Extra model 17");
|
|
107
|
+
await click("Thread body");
|
|
108
|
+
expect(setup.captureCharFrame()).not.toContain("First model");
|
|
109
|
+
} finally {
|
|
110
|
+
setup.renderer.destroy();
|
|
111
|
+
}
|
|
112
|
+
});
|