@cueloop/client 0.1.0-alpha.103 → 0.1.0-alpha.105
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 +10 -10
- package/src/app/App.readonly.test.tsx +63 -0
- package/src/app/App.tsx +27 -11
- package/src/app/app-screens.tsx +2 -2
- package/src/app/app-view-model.ts +4 -1
- package/src/index.ts +1 -0
- package/src/integrations/share.ts +42 -1
- package/src/settings/config.test.ts +38 -1
- package/src/settings/config.ts +52 -6
- package/src/settings/use-settings-dialog.test.tsx +75 -1
- package/src/settings/use-settings-dialog.tsx +78 -1
- package/src/thread/components/AgentConfigControls.test.tsx +41 -0
- package/src/thread/components/AgentConfigControls.tsx +7 -1
- package/src/thread/components/AgentThreadPane.test.tsx +60 -3
- package/src/thread/components/AgentThreadPane.tsx +49 -0
- package/src/thread/thread-controller.share.test.ts +47 -2
- package/src/thread/thread-controller.ts +6 -4
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.105",
|
|
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,15 +27,15 @@
|
|
|
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.
|
|
34
|
-
"@opentui/core": "^0.5.
|
|
35
|
-
"@opentui/keymap": "0.5.
|
|
36
|
-
"@opentui/react": "^0.5.
|
|
37
|
-
"@opentui/ssh": "0.5.
|
|
38
|
-
"@pierre/diffs": "^1.5.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.105",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.105",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.105",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.105",
|
|
34
|
+
"@opentui/core": "^0.5.17",
|
|
35
|
+
"@opentui/keymap": "0.5.17",
|
|
36
|
+
"@opentui/react": "^0.5.17",
|
|
37
|
+
"@opentui/ssh": "0.5.17",
|
|
38
|
+
"@pierre/diffs": "^1.5.2",
|
|
39
39
|
"@tanstack/virtual-core": "^3.17.11",
|
|
40
40
|
"diff": "^9",
|
|
41
41
|
"puppeteer-core": "^25.12.0",
|
|
@@ -5,6 +5,8 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import React from "react";
|
|
8
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
9
|
+
import { NERD } from "../ui/components/primitives/icons";
|
|
8
10
|
import { DaemonServer } from "@cueloop/daemon";
|
|
9
11
|
import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
|
|
10
12
|
import { App } from "./App";
|
|
@@ -208,3 +210,64 @@ describe("observer navigation still works", () => {
|
|
|
208
210
|
expect(snapshot().annotations).toBe(1);
|
|
209
211
|
});
|
|
210
212
|
});
|
|
213
|
+
|
|
214
|
+
for (const role of ["collaborator", "observer"] as const) {
|
|
215
|
+
test(`${role} shared view shows a conversation snapshot and has no Thread actions menu`, async () => {
|
|
216
|
+
const client = await DaemonClient.connect({ home });
|
|
217
|
+
const shared = {
|
|
218
|
+
...session,
|
|
219
|
+
artifact: { ...session.artifact, content: "" },
|
|
220
|
+
agentSnapshot: {
|
|
221
|
+
threadId: session.id,
|
|
222
|
+
phase: { kind: "idle" as const },
|
|
223
|
+
tools: [],
|
|
224
|
+
comments: [],
|
|
225
|
+
messages: [
|
|
226
|
+
{
|
|
227
|
+
id: "prompt",
|
|
228
|
+
role: "user" as const,
|
|
229
|
+
text: "Shared question",
|
|
230
|
+
complete: true,
|
|
231
|
+
revision: 1,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: "answer",
|
|
235
|
+
role: "agent" as const,
|
|
236
|
+
text: "Shared conversation answer",
|
|
237
|
+
complete: true,
|
|
238
|
+
revision: 1,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
client.sessionGet = async () => shared;
|
|
245
|
+
client.sessionList = async () => [shared];
|
|
246
|
+
const setup = await renderReadyApp(
|
|
247
|
+
<App
|
|
248
|
+
home={home}
|
|
249
|
+
sessionId={shared.id}
|
|
250
|
+
role={role}
|
|
251
|
+
openClient={() => Promise.resolve(client)}
|
|
252
|
+
/>,
|
|
253
|
+
{ width: 120, height: 32 },
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await waitForText(setup, "Shared conversation answer");
|
|
258
|
+
expect(setup.captureCharFrame()).toContain("Shared question");
|
|
259
|
+
const row = setup
|
|
260
|
+
.captureCharFrame()
|
|
261
|
+
.split("\n")
|
|
262
|
+
.findIndex((line) => line.slice(0, 30).includes("Migration Plan"));
|
|
263
|
+
|
|
264
|
+
expect(row).toBeGreaterThanOrEqual(0);
|
|
265
|
+
await setup.mockMouse.moveTo(12, row);
|
|
266
|
+
expect(setup.captureCharFrame()).not.toContain(NERD.kebab);
|
|
267
|
+
expect(setup.captureCharFrame()).not.toContain(" edit ");
|
|
268
|
+
} finally {
|
|
269
|
+
setup.renderer.destroy();
|
|
270
|
+
client.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
package/src/app/App.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { THREAD_MODEL_DEFAULTS } from "@cueloop/schema";
|
|
1
2
|
import { useThreadHarnessSetting } from "../settings/use-thread-harness-setting";
|
|
2
3
|
import React, {
|
|
3
4
|
useCallback,
|
|
@@ -559,7 +560,11 @@ export function App({
|
|
|
559
560
|
servedArtifact,
|
|
560
561
|
extensionRegistry,
|
|
561
562
|
}: AppProps): React.ReactNode {
|
|
562
|
-
const { observer, isOwner } = computeRoleCapabilities(
|
|
563
|
+
const { observer, isOwner, canManageThreads } = computeRoleCapabilities(
|
|
564
|
+
readOnly,
|
|
565
|
+
role,
|
|
566
|
+
isSharedThreadConnection(openClient, shareTransport),
|
|
567
|
+
);
|
|
563
568
|
const controller = useMemo(
|
|
564
569
|
() =>
|
|
565
570
|
createReviewController({
|
|
@@ -686,6 +691,7 @@ export function App({
|
|
|
686
691
|
const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
|
|
687
692
|
const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
|
|
688
693
|
const [skills, setSkills] = useState<SlashItem[]>([]);
|
|
694
|
+
const [threadModelDefaults, setThreadModelDefaults] = useState(THREAD_MODEL_DEFAULTS);
|
|
689
695
|
const [reviewSkill, setReviewSkill] = useState("code-review");
|
|
690
696
|
const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
|
|
691
697
|
const paletteNames = useMemo(
|
|
@@ -702,6 +708,7 @@ export function App({
|
|
|
702
708
|
setPrototypePixels(config.experimental.prototypePixels);
|
|
703
709
|
setThreadAgentEnabled(config.experimental.threadAgent);
|
|
704
710
|
setThreadHarness(config.thread.harness);
|
|
711
|
+
setThreadModelDefaults({ pi: config.thread.pi, fx: config.thread.fx });
|
|
705
712
|
keysRef.current = config.keys;
|
|
706
713
|
keyBindings.setKeys(config.keys);
|
|
707
714
|
setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
|
|
@@ -798,6 +805,8 @@ export function App({
|
|
|
798
805
|
onCategorySelect,
|
|
799
806
|
openSettings,
|
|
800
807
|
} = useSettingsDialog({
|
|
808
|
+
threadDefaults: threadModelDefaults,
|
|
809
|
+
setThreadDefaults: setThreadModelDefaults,
|
|
801
810
|
theme,
|
|
802
811
|
appearance,
|
|
803
812
|
autoClose,
|
|
@@ -1221,8 +1230,10 @@ export function App({
|
|
|
1221
1230
|
onFocusPane={setFocusedPane}
|
|
1222
1231
|
menuControl={menuControl}
|
|
1223
1232
|
pinnedIds={pinnedIds}
|
|
1224
|
-
onPin={togglePin}
|
|
1225
|
-
onRename={(
|
|
1233
|
+
onPin={enabledThreadAction(canManageThreads, togglePin)}
|
|
1234
|
+
onRename={enabledThreadAction(canManageThreads, (id: string, title: string) =>
|
|
1235
|
+
setMode({ type: "renameThread", sessionId: id, text: title }),
|
|
1236
|
+
)}
|
|
1226
1237
|
quickActions={quickActions}
|
|
1227
1238
|
diffView={diffView}
|
|
1228
1239
|
onWelcomeComposingChange={setWelcomeComposing}
|
|
@@ -1351,13 +1362,15 @@ export function App({
|
|
|
1351
1362
|
width={30}
|
|
1352
1363
|
onSelect={openThread}
|
|
1353
1364
|
onCreateThread={createEmptyThread}
|
|
1354
|
-
onPin={togglePin}
|
|
1355
|
-
onRename={(id, title) =>
|
|
1356
|
-
setMode({ type: "renameThread", sessionId: id, text: title })
|
|
1357
|
-
}
|
|
1358
|
-
onRequestDelete={(
|
|
1359
|
-
|
|
1360
|
-
|
|
1365
|
+
onPin={enabledThreadAction(canManageThreads, togglePin)}
|
|
1366
|
+
onRename={enabledThreadAction(canManageThreads, (id: string, title: string) =>
|
|
1367
|
+
setMode({ type: "renameThread", sessionId: id, text: title }),
|
|
1368
|
+
)}
|
|
1369
|
+
onRequestDelete={enabledThreadAction(
|
|
1370
|
+
canManageThreads,
|
|
1371
|
+
(id: string, title: string) =>
|
|
1372
|
+
setMode({ type: "confirmDelete", sessionId: id, title }),
|
|
1373
|
+
)}
|
|
1361
1374
|
theme={theme}
|
|
1362
1375
|
/>
|
|
1363
1376
|
</scrollbox>
|
|
@@ -1684,7 +1697,10 @@ export function App({
|
|
|
1684
1697
|
);
|
|
1685
1698
|
}
|
|
1686
1699
|
|
|
1687
|
-
function enabledThreadAction
|
|
1700
|
+
function enabledThreadAction<Arguments extends unknown[]>(
|
|
1701
|
+
enabled: boolean,
|
|
1702
|
+
action: (...args: Arguments) => void,
|
|
1703
|
+
): ((...args: Arguments) => void) | undefined {
|
|
1688
1704
|
return enabled ? action : undefined;
|
|
1689
1705
|
}
|
|
1690
1706
|
|
package/src/app/app-screens.tsx
CHANGED
|
@@ -175,8 +175,8 @@ export function NoThreadShell(props: {
|
|
|
175
175
|
/** App owns the one open-menu id, so the bare shell's menus share App's modal keyboard handling. */
|
|
176
176
|
menuControl: MenuControlApi;
|
|
177
177
|
pinnedIds: ReadonlySet<string>;
|
|
178
|
-
onPin
|
|
179
|
-
onRename
|
|
178
|
+
onPin?: (id: string) => void;
|
|
179
|
+
onRename?: (id: string, title: string) => void;
|
|
180
180
|
/** Drives the "/" quick actions and skills in the welcome playground's composer. */
|
|
181
181
|
quickActions: QuickAction[];
|
|
182
182
|
diffView: DiffViewMode;
|
|
@@ -12,10 +12,13 @@ import type { BreadcrumbItem } from "./components/Breadcrumb";
|
|
|
12
12
|
export function computeRoleCapabilities(
|
|
13
13
|
readOnly: boolean,
|
|
14
14
|
role: "owner" | "observer" | "collaborator",
|
|
15
|
+
shared = false,
|
|
15
16
|
) {
|
|
16
17
|
const observer = readOnly || role === "observer";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
const isOwner = !observer && role === "owner";
|
|
20
|
+
|
|
21
|
+
return { observer, isOwner, canManageThreads: isOwner && !shared };
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export function deriveReviewFlags(session: Thread | null) {
|
package/src/index.ts
CHANGED
|
@@ -19,9 +19,10 @@ import {
|
|
|
19
19
|
type SharePolicy,
|
|
20
20
|
type ShareLink,
|
|
21
21
|
type Thread,
|
|
22
|
+
type ThreadAgentState,
|
|
22
23
|
} from "@cueloop/schema";
|
|
23
24
|
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
24
|
-
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
25
|
+
import type { ThreadClient, SharedMerge } from "@cueloop/daemon/client";
|
|
25
26
|
import * as v from "valibot";
|
|
26
27
|
import { copyToClipboard } from "./clipboard";
|
|
27
28
|
|
|
@@ -240,3 +241,43 @@ async function runShareSsh(
|
|
|
240
241
|
|
|
241
242
|
return { stdout, stderr, code };
|
|
242
243
|
}
|
|
244
|
+
|
|
245
|
+
/** Freeze the visible conversation without owner session IDs, execution data or delivery receipts. */
|
|
246
|
+
export async function snapshotThreadConversation(
|
|
247
|
+
thread: Thread,
|
|
248
|
+
client: Pick<ThreadClient, "agentGet">,
|
|
249
|
+
): Promise<Thread> {
|
|
250
|
+
if (!client.agentGet) return thread;
|
|
251
|
+
|
|
252
|
+
let state: ThreadAgentState;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
state = await client.agentGet(thread.id);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error instanceof Error && error.message.startsWith("Thread agent is disabled;"))
|
|
258
|
+
return thread;
|
|
259
|
+
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!state.messages.length && !state.submissions?.length) return thread;
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
...thread,
|
|
267
|
+
agentSnapshot: {
|
|
268
|
+
threadId: thread.id,
|
|
269
|
+
harness: state.harness ? { id: state.harness.id, label: state.harness.label } : undefined,
|
|
270
|
+
phase: { kind: "idle" },
|
|
271
|
+
messages: state.messages,
|
|
272
|
+
comments: state.comments,
|
|
273
|
+
tools: [],
|
|
274
|
+
submissions: state.submissions?.map(({ id, commentId, prompt, quote, status }) => ({
|
|
275
|
+
id,
|
|
276
|
+
commentId,
|
|
277
|
+
prompt,
|
|
278
|
+
quote,
|
|
279
|
+
status,
|
|
280
|
+
})),
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
persistLayout,
|
|
13
13
|
persistPins,
|
|
14
14
|
persistReviewSkill,
|
|
15
|
+
persistThreadModelDefault,
|
|
15
16
|
persistReviewWorkspace,
|
|
16
17
|
persistTheme,
|
|
17
18
|
quickActionBody,
|
|
@@ -715,3 +716,39 @@ test("a malformed Thread section does not discard other preferences", () => {
|
|
|
715
716
|
rmSync(dir, { recursive: true, force: true });
|
|
716
717
|
}
|
|
717
718
|
});
|
|
719
|
+
|
|
720
|
+
test("Thread model defaults layer independently and persistence stays in the selected table", () => {
|
|
721
|
+
const directory = mkdtempSync(join(tmpdir(), "cueloop-model-defaults-"));
|
|
722
|
+
const user = join(directory, "user.toml");
|
|
723
|
+
const repository = join(directory, "repo");
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
const defaults = loadConfig({ userConfigPath: user }).thread;
|
|
727
|
+
|
|
728
|
+
expect(defaults.pi).toEqual({ model: "openai-codex/gpt-6.1-sol", effort: "medium" });
|
|
729
|
+
expect(defaults.fx).toEqual({ model: "grok/grok-4.7", effort: "medium" });
|
|
730
|
+
writeFileSync(
|
|
731
|
+
user,
|
|
732
|
+
'[thread.pi]\nmodel = "openai-codex/custom"\neffort = "high"\n\n[thread.fx]\nmodel = "grok/custom"\neffort = "low"\n',
|
|
733
|
+
);
|
|
734
|
+
persistThreadModelDefault("pi", "effort", "medium", user);
|
|
735
|
+
persistThreadModelDefault("fx", "model", "grok/grok-4.7", user);
|
|
736
|
+
|
|
737
|
+
const config = loadConfig({ userConfigPath: user });
|
|
738
|
+
|
|
739
|
+
expect(config.thread.pi).toEqual({ model: "openai-codex/custom", effort: "medium" });
|
|
740
|
+
expect(config.thread.fx).toEqual({ model: "grok/grok-4.7", effort: "low" });
|
|
741
|
+
mkdirSync(join(repository, ".cueloop"), { recursive: true });
|
|
742
|
+
writeFileSync(
|
|
743
|
+
join(repository, ".cueloop/config.toml"),
|
|
744
|
+
'[thread.pi]\neffort = "high"\n[thread.fx]\nmodel = " "\neffort = "invalid"\n',
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const layered = loadConfig({ userConfigPath: user, repoRoot: repository });
|
|
748
|
+
|
|
749
|
+
expect(layered.thread.pi).toEqual({ model: "openai-codex/custom", effort: "high" });
|
|
750
|
+
expect(layered.thread.fx).toEqual(config.thread.fx);
|
|
751
|
+
} finally {
|
|
752
|
+
rmSync(directory, { recursive: true, force: true });
|
|
753
|
+
}
|
|
754
|
+
});
|
package/src/settings/config.ts
CHANGED
|
@@ -9,7 +9,13 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
AGENT_REASONING_EFFORTS,
|
|
14
|
+
THREAD_MODEL_DEFAULTS,
|
|
15
|
+
type ThreadModelDefaults,
|
|
16
|
+
MESSAGE_OUTCOMES,
|
|
17
|
+
type MessageOutcome,
|
|
18
|
+
} from "@cueloop/schema";
|
|
13
19
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
20
|
import { dirname } from "node:path";
|
|
15
21
|
import * as v from "valibot";
|
|
@@ -128,7 +134,7 @@ export interface CueloopConfig {
|
|
|
128
134
|
actions: QuickAction[];
|
|
129
135
|
skillsPath: string;
|
|
130
136
|
review: ReviewConfig;
|
|
131
|
-
thread: { harness: "pi" | "fx" };
|
|
137
|
+
thread: { harness: "pi" | "fx"; pi: ThreadModelDefaults; fx: ThreadModelDefaults };
|
|
132
138
|
integrations: IntegrationsConfig;
|
|
133
139
|
experimental: ExperimentalConfig;
|
|
134
140
|
}
|
|
@@ -180,6 +186,11 @@ function skillsPathFrom(
|
|
|
180
186
|
return raw?.path?.trim() ? raw.path.trim() : fallback;
|
|
181
187
|
}
|
|
182
188
|
|
|
189
|
+
const ThreadModelDefaultsSchema = v.object({
|
|
190
|
+
model: v.fallback(v.optional(v.pipe(v.string(), v.trim(), v.minLength(1))), undefined),
|
|
191
|
+
effort: v.fallback(v.optional(v.picklist(AGENT_REASONING_EFFORTS)), undefined),
|
|
192
|
+
});
|
|
193
|
+
|
|
183
194
|
const ConfigDocumentSchema = v.object({
|
|
184
195
|
actions: v.optional(v.array(v.unknown())),
|
|
185
196
|
authors: v.optional(v.unknown()),
|
|
@@ -192,7 +203,13 @@ const ConfigDocumentSchema = v.object({
|
|
|
192
203
|
skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
|
|
193
204
|
review: v.optional(v.unknown()),
|
|
194
205
|
thread: v.fallback(
|
|
195
|
-
v.optional(
|
|
206
|
+
v.optional(
|
|
207
|
+
v.object({
|
|
208
|
+
harness: v.fallback(v.optional(v.picklist(["pi", "fx"])), undefined),
|
|
209
|
+
pi: v.fallback(v.optional(ThreadModelDefaultsSchema), undefined),
|
|
210
|
+
fx: v.fallback(v.optional(ThreadModelDefaultsSchema), undefined),
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
196
213
|
undefined,
|
|
197
214
|
),
|
|
198
215
|
});
|
|
@@ -324,6 +341,21 @@ function applyReview(review: ReviewConfig, parsed: v.InferOutput<typeof ReviewSc
|
|
|
324
341
|
if (parsed.workspace !== undefined) review.workspace = parsed.workspace;
|
|
325
342
|
}
|
|
326
343
|
|
|
344
|
+
function applyThreadDefaults(
|
|
345
|
+
thread: CueloopConfig["thread"],
|
|
346
|
+
parsed: v.InferOutput<typeof ConfigDocumentSchema>["thread"],
|
|
347
|
+
): void {
|
|
348
|
+
if (parsed?.harness) thread.harness = parsed.harness;
|
|
349
|
+
|
|
350
|
+
for (const harness of ["pi", "fx"] as const) {
|
|
351
|
+
const defaults = parsed?.[harness];
|
|
352
|
+
|
|
353
|
+
if (defaults?.model) thread[harness].model = defaults.model;
|
|
354
|
+
|
|
355
|
+
if (defaults?.effort) thread[harness].effort = defaults.effort;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
327
359
|
function layer(
|
|
328
360
|
base: CueloopConfig,
|
|
329
361
|
raw: v.InferOutput<typeof ConfigDocumentSchema>,
|
|
@@ -339,7 +371,7 @@ function layer(
|
|
|
339
371
|
actions: [...base.actions],
|
|
340
372
|
skillsPath: base.skillsPath,
|
|
341
373
|
review: { ...base.review },
|
|
342
|
-
thread: { ...base.thread },
|
|
374
|
+
thread: { ...base.thread, pi: { ...base.thread.pi }, fx: { ...base.thread.fx } },
|
|
343
375
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
344
376
|
experimental: { ...base.experimental },
|
|
345
377
|
};
|
|
@@ -390,7 +422,7 @@ function layer(
|
|
|
390
422
|
|
|
391
423
|
if (review.success) applyReview(out.review, review.output);
|
|
392
424
|
|
|
393
|
-
|
|
425
|
+
applyThreadDefaults(out.thread, raw.thread);
|
|
394
426
|
|
|
395
427
|
return out;
|
|
396
428
|
}
|
|
@@ -414,7 +446,11 @@ export function loadConfig(
|
|
|
414
446
|
actions: [...DEFAULT_QUICK_ACTIONS],
|
|
415
447
|
skillsPath: join(homedir(), ".agents", "skills"),
|
|
416
448
|
review: { skill: "code-review", workspace: "worktree" },
|
|
417
|
-
thread: {
|
|
449
|
+
thread: {
|
|
450
|
+
harness: "pi",
|
|
451
|
+
pi: { ...THREAD_MODEL_DEFAULTS.pi },
|
|
452
|
+
fx: { ...THREAD_MODEL_DEFAULTS.fx },
|
|
453
|
+
},
|
|
418
454
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
419
455
|
experimental: { prototypePixels: false, threadAgent: false },
|
|
420
456
|
};
|
|
@@ -670,3 +706,13 @@ function applyExperimental(
|
|
|
670
706
|
export function persistThreadHarness(harness: "pi" | "fx", userConfigPath?: string): void {
|
|
671
707
|
persistTableSetting("thread", "harness", tomlString(harness), userConfigPath);
|
|
672
708
|
}
|
|
709
|
+
|
|
710
|
+
/** Persist one harness's new-Thread defaults without altering other TOML tables. */
|
|
711
|
+
export function persistThreadModelDefault(
|
|
712
|
+
harness: "pi" | "fx",
|
|
713
|
+
key: keyof ThreadModelDefaults,
|
|
714
|
+
value: string,
|
|
715
|
+
userConfigPath?: string,
|
|
716
|
+
): void {
|
|
717
|
+
persistTableSetting(`thread.${harness}`, key, tomlString(value), userConfigPath);
|
|
718
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadConfig } from "./config";
|
|
1
5
|
import { expect, test } from "bun:test";
|
|
2
6
|
import React, { useState } from "react";
|
|
7
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
8
|
import { testRender } from "@opentui/react/test-utils";
|
|
4
9
|
import { DARK } from "../appearance/theme";
|
|
5
|
-
import { settle, locateText } from "../testing/test-support";
|
|
10
|
+
import { settle, locateText, typeText } from "../testing/test-support";
|
|
6
11
|
import { SettingsDialog } from "./components/SettingsDialog";
|
|
7
12
|
import { useSettingsDialog } from "./use-settings-dialog";
|
|
8
13
|
|
|
@@ -14,7 +19,14 @@ function TestThreadSettings({
|
|
|
14
19
|
onSwitch: (harness: string) => void;
|
|
15
20
|
}) {
|
|
16
21
|
const [harness, setHarness] = useState<"pi" | "fx">("pi");
|
|
22
|
+
const [threadDefaults, setThreadDefaults] = useState(() => {
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
|
|
25
|
+
return { pi: config.thread.pi, fx: config.thread.fx };
|
|
26
|
+
});
|
|
17
27
|
const model = useSettingsDialog({
|
|
28
|
+
threadDefaults,
|
|
29
|
+
setThreadDefaults,
|
|
18
30
|
theme: DARK,
|
|
19
31
|
appearance: "dark",
|
|
20
32
|
autoClose: "off",
|
|
@@ -42,6 +54,8 @@ function TestThreadSettings({
|
|
|
42
54
|
},
|
|
43
55
|
});
|
|
44
56
|
|
|
57
|
+
useKeyboard((key) => model.handleSettingsKey(key.name));
|
|
58
|
+
|
|
45
59
|
return (
|
|
46
60
|
<SettingsDialog
|
|
47
61
|
isOpen
|
|
@@ -96,3 +110,63 @@ test("Thread settings switches harness by mouse and disappears without owner cap
|
|
|
96
110
|
disabled.renderer.destroy();
|
|
97
111
|
}
|
|
98
112
|
});
|
|
113
|
+
|
|
114
|
+
test("Thread settings edits model defaults and effort and persists them to TOML", async () => {
|
|
115
|
+
const directory = mkdtempSync(join(tmpdir(), "cueloop-settings-model-"));
|
|
116
|
+
const previous = process.env.CUELOOP_CONFIG;
|
|
117
|
+
const path = join(directory, "config.toml");
|
|
118
|
+
|
|
119
|
+
process.env.CUELOOP_CONFIG = path;
|
|
120
|
+
const setup = await testRender(<TestThreadSettings enabled onSwitch={() => {}} />, {
|
|
121
|
+
width: 100,
|
|
122
|
+
height: 30,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await settle(setup);
|
|
127
|
+
const category = locateText(setup, "Thread");
|
|
128
|
+
|
|
129
|
+
await setup.mockMouse.click(category.column, category.row);
|
|
130
|
+
await settle(setup);
|
|
131
|
+
expect(setup.captureCharFrame()).toContain("gpt-6.1-sol");
|
|
132
|
+
expect(setup.captureCharFrame()).toContain("grok-4.7");
|
|
133
|
+
const effort = locateText(setup, "pi default effort");
|
|
134
|
+
|
|
135
|
+
await setup.mockMouse.click(effort.column, effort.row);
|
|
136
|
+
await settle(setup);
|
|
137
|
+
expect(loadConfig({ userConfigPath: path }).thread.pi.effort).toBe("high");
|
|
138
|
+
const model = locateText(setup, "pi default model");
|
|
139
|
+
|
|
140
|
+
await setup.mockMouse.click(model.column, model.row);
|
|
141
|
+
await settle(setup);
|
|
142
|
+
await typeText(setup, "-custom");
|
|
143
|
+
await settle(setup);
|
|
144
|
+
const save = locateText(setup, "save");
|
|
145
|
+
|
|
146
|
+
await setup.mockMouse.click(save.column, save.row);
|
|
147
|
+
await settle(setup);
|
|
148
|
+
expect(loadConfig({ userConfigPath: path }).thread.pi.model).toBe(
|
|
149
|
+
"openai-codex/gpt-6.1-sol-custom",
|
|
150
|
+
);
|
|
151
|
+
expect(setup.captureCharFrame()).toContain("gpt-6.1-sol-custom");
|
|
152
|
+
const fxModel = locateText(setup, "fx default model");
|
|
153
|
+
|
|
154
|
+
await setup.mockMouse.click(fxModel.column, fxModel.row);
|
|
155
|
+
await settle(setup);
|
|
156
|
+
await typeText(setup, "-cancelled");
|
|
157
|
+
setup.mockInput.pressKey("ESC");
|
|
158
|
+
await settle(setup);
|
|
159
|
+
expect(setup.captureCharFrame()).not.toContain("Default model for new Threads");
|
|
160
|
+
expect(loadConfig({ userConfigPath: path }).thread.fx).toEqual({
|
|
161
|
+
model: "grok/grok-4.7",
|
|
162
|
+
effort: "medium",
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
setup.renderer.destroy();
|
|
166
|
+
|
|
167
|
+
if (previous === undefined) delete process.env.CUELOOP_CONFIG;
|
|
168
|
+
else process.env.CUELOOP_CONFIG = previous;
|
|
169
|
+
|
|
170
|
+
rmSync(directory, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, type Dispatch, type SetStateAction } from "react";
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_QUICK_ACTIONS,
|
|
4
|
+
persistThreadModelDefault,
|
|
4
5
|
persistActions,
|
|
5
6
|
persistAutoClose,
|
|
6
7
|
persistDiffView,
|
|
@@ -18,6 +19,12 @@ import {
|
|
|
18
19
|
type Appearance,
|
|
19
20
|
type ThemeName,
|
|
20
21
|
} from "../appearance/theme-presets";
|
|
22
|
+
import {
|
|
23
|
+
AGENT_REASONING_EFFORTS,
|
|
24
|
+
type ThreadModelDefaults,
|
|
25
|
+
type AgentReasoningEffort,
|
|
26
|
+
} from "@cueloop/schema";
|
|
27
|
+
import { PromptDialog } from "../ui/components/PromptDialog";
|
|
21
28
|
import type { Theme } from "../appearance/theme";
|
|
22
29
|
import type { SettingsCategory } from "./components/SettingsDialog";
|
|
23
30
|
import { QuickActionsEditor } from "./components/quick-actions-editor";
|
|
@@ -78,6 +85,8 @@ function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): Settings
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
export function useSettingsDialog(params: {
|
|
88
|
+
threadDefaults: Record<"pi" | "fx", ThreadModelDefaults>;
|
|
89
|
+
setThreadDefaults: Dispatch<SetStateAction<Record<"pi" | "fx", ThreadModelDefaults>>>;
|
|
81
90
|
theme: Theme;
|
|
82
91
|
appearance: Appearance;
|
|
83
92
|
autoClose: AutoClose;
|
|
@@ -128,6 +137,22 @@ export function useSettingsDialog(params: {
|
|
|
128
137
|
onSwitchHarness,
|
|
129
138
|
} = params;
|
|
130
139
|
|
|
140
|
+
const { threadDefaults, setThreadDefaults } = params;
|
|
141
|
+
const [modelEditor, setModelEditor] = useState<{ harness: "pi" | "fx"; text: string } | null>(
|
|
142
|
+
null,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const saveModelDefault = (): void => {
|
|
146
|
+
if (!modelEditor?.text.trim()) return;
|
|
147
|
+
|
|
148
|
+
const { harness, text } = modelEditor;
|
|
149
|
+
const model = text.trim();
|
|
150
|
+
|
|
151
|
+
persistThreadModelDefault(harness, "model", model);
|
|
152
|
+
setThreadDefaults((defaults) => ({ ...defaults, [harness]: { ...defaults[harness], model } }));
|
|
153
|
+
setModelEditor(null);
|
|
154
|
+
};
|
|
155
|
+
|
|
131
156
|
// open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
|
|
132
157
|
const [settingsNav, setSettingsNav] = useState<SettingsNav>({
|
|
133
158
|
categoryId: "general",
|
|
@@ -237,7 +262,33 @@ export function useSettingsDialog(params: {
|
|
|
237
262
|
kind: "cycle" as const,
|
|
238
263
|
options: ["pi", "fx"],
|
|
239
264
|
},
|
|
265
|
+
{ key: "piModel", label: "pi default model", kind: "text" as const },
|
|
266
|
+
{
|
|
267
|
+
key: "piEffort",
|
|
268
|
+
label: "pi default effort",
|
|
269
|
+
kind: "cycle" as const,
|
|
270
|
+
options: [...AGENT_REASONING_EFFORTS],
|
|
271
|
+
},
|
|
272
|
+
{ key: "fxModel", label: "fx default model", kind: "text" as const },
|
|
273
|
+
{
|
|
274
|
+
key: "fxEffort",
|
|
275
|
+
label: "fx default effort",
|
|
276
|
+
kind: "cycle" as const,
|
|
277
|
+
options: [...AGENT_REASONING_EFFORTS],
|
|
278
|
+
},
|
|
240
279
|
],
|
|
280
|
+
customBody: modelEditor ? (
|
|
281
|
+
<PromptDialog
|
|
282
|
+
isOpen
|
|
283
|
+
title=" Default model for new Threads "
|
|
284
|
+
label={`${modelEditor.harness} model (provider/model)`}
|
|
285
|
+
value={modelEditor.text}
|
|
286
|
+
onInput={(text) => setModelEditor({ ...modelEditor, text })}
|
|
287
|
+
onSave={saveModelDefault}
|
|
288
|
+
onCancel={() => setModelEditor(null)}
|
|
289
|
+
theme={theme}
|
|
290
|
+
/>
|
|
291
|
+
) : undefined,
|
|
241
292
|
},
|
|
242
293
|
]
|
|
243
294
|
: []),
|
|
@@ -280,9 +331,28 @@ export function useSettingsDialog(params: {
|
|
|
280
331
|
reviewSkill,
|
|
281
332
|
reviewWorkspace,
|
|
282
333
|
threadHarness: threadHarness ?? "pi",
|
|
334
|
+
piModel: threadDefaults.pi.model.replace(/^[^/]+\//, ""),
|
|
335
|
+
piEffort: threadDefaults.pi.effort,
|
|
336
|
+
fxModel: threadDefaults.fx.model.replace(/^[^/]+\//, ""),
|
|
337
|
+
fxEffort: threadDefaults.fx.effort,
|
|
283
338
|
};
|
|
284
339
|
const cycleSetting = (rowKey: string): void => {
|
|
285
|
-
if (rowKey === "
|
|
340
|
+
if (rowKey === "piModel" || rowKey === "fxModel") {
|
|
341
|
+
const harness = rowKey === "piModel" ? "pi" : "fx";
|
|
342
|
+
|
|
343
|
+
setModelEditor({ harness, text: threadDefaults[harness].model });
|
|
344
|
+
} else if (rowKey === "piEffort" || rowKey === "fxEffort") {
|
|
345
|
+
const harness = rowKey === "piEffort" ? "pi" : "fx";
|
|
346
|
+
const efforts = AGENT_REASONING_EFFORTS;
|
|
347
|
+
const effort: AgentReasoningEffort =
|
|
348
|
+
efforts[(efforts.indexOf(threadDefaults[harness].effort) + 1) % efforts.length]!;
|
|
349
|
+
|
|
350
|
+
persistThreadModelDefault(harness, "effort", effort);
|
|
351
|
+
setThreadDefaults((defaults) => ({
|
|
352
|
+
...defaults,
|
|
353
|
+
[harness]: { ...defaults[harness], effort },
|
|
354
|
+
}));
|
|
355
|
+
} else if (rowKey === "threadHarness") {
|
|
286
356
|
onSwitchHarness?.(threadHarness === "fx" ? "pi" : "fx");
|
|
287
357
|
} else if (rowKey === "autoClose") {
|
|
288
358
|
const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
|
|
@@ -327,6 +397,13 @@ export function useSettingsDialog(params: {
|
|
|
327
397
|
};
|
|
328
398
|
|
|
329
399
|
const handleSettingsKey = (name: string): void => {
|
|
400
|
+
if (modelEditor) {
|
|
401
|
+
if (name === "escape") setModelEditor(null);
|
|
402
|
+
else if (name === "return") saveModelDefault();
|
|
403
|
+
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
330
407
|
// an open system-prompt input owns typing; only esc (close it) escapes here
|
|
331
408
|
if (actionsExpandedIndex !== null) {
|
|
332
409
|
if (name === "escape") setActionsExpandedIndex(null);
|
|
@@ -110,3 +110,44 @@ test("model and reasoning choices share the existing overlay and close after sel
|
|
|
110
110
|
setup.renderer.destroy();
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
for (const harness of ["pi", "fx"]) {
|
|
115
|
+
test(`${harness} footer identifies its harness, subscription, model and effort`, async () => {
|
|
116
|
+
const setup = await testRender(
|
|
117
|
+
<AgentConfigControls
|
|
118
|
+
theme={DARK}
|
|
119
|
+
onConfigure={() => {}}
|
|
120
|
+
state={{
|
|
121
|
+
...initial,
|
|
122
|
+
harness: { id: harness, label: harness },
|
|
123
|
+
configOptions: [
|
|
124
|
+
{
|
|
125
|
+
id: "provider",
|
|
126
|
+
name: "Provider",
|
|
127
|
+
currentValue: harness,
|
|
128
|
+
options: [
|
|
129
|
+
{
|
|
130
|
+
value: harness,
|
|
131
|
+
name: harness === "pi" ? "Codex subscription" : "Grok subscription",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
...initial.configOptions!,
|
|
136
|
+
],
|
|
137
|
+
}}
|
|
138
|
+
/>,
|
|
139
|
+
{ width: 100, height: 3 },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await waitForText(setup, "First model");
|
|
144
|
+
const frame = setup.captureCharFrame();
|
|
145
|
+
|
|
146
|
+
expect(frame).toContain(harness);
|
|
147
|
+
expect(frame).toContain(harness === "pi" ? "Codex subscription" : "Grok subscription");
|
|
148
|
+
expect(frame).toContain("Medium");
|
|
149
|
+
} finally {
|
|
150
|
+
setup.renderer.destroy();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -18,10 +18,12 @@ export function AgentConfigControls({
|
|
|
18
18
|
}): React.ReactNode {
|
|
19
19
|
return (
|
|
20
20
|
<box style={{ flexDirection: "row", gap: 2, marginRight: 2 }}>
|
|
21
|
+
{state.harness ? <text fg={theme.textMuted}>{state.harness.label}</text> : null}
|
|
21
22
|
{state.configOptions
|
|
22
23
|
?.filter(
|
|
23
24
|
(option) =>
|
|
24
25
|
option.category === "thought_level" ||
|
|
26
|
+
option.id === "effort" ||
|
|
25
27
|
option.id === "model" ||
|
|
26
28
|
option.id === "provider",
|
|
27
29
|
)
|
|
@@ -104,7 +106,11 @@ function AgentConfigMenu({
|
|
|
104
106
|
return (
|
|
105
107
|
<box
|
|
106
108
|
ref={ref}
|
|
107
|
-
onMouseUp={
|
|
109
|
+
onMouseUp={
|
|
110
|
+
disabled || option.options.length < 2
|
|
111
|
+
? undefined
|
|
112
|
+
: () => menu.toggleMenu(`agent-config:${option.id}`)
|
|
113
|
+
}
|
|
108
114
|
>
|
|
109
115
|
<text fg={disabled ? theme.textDim : theme.textMuted}>
|
|
110
116
|
{option.options.find((choice) => choice.value === option.currentValue)?.name ??
|
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
} from "@cueloop/schema";
|
|
10
10
|
import { AgentThreadPane, AgentThreadPrototype } from "./AgentThreadPane";
|
|
11
11
|
import { ThreadFooter } from "./ThreadFooter";
|
|
12
|
-
import { ThreadView } from "../../markdown/components/ThreadView";
|
|
12
|
+
import { ThreadView, type ThreadViewProps } from "../../markdown/components/ThreadView";
|
|
13
13
|
import { buildDisplay, marksByDisplay } from "../../markdown/view-plan";
|
|
14
14
|
import { DARK } from "../../appearance/theme";
|
|
15
15
|
import {
|
|
16
16
|
locateText,
|
|
17
|
+
dragText,
|
|
17
18
|
waitForText,
|
|
18
19
|
pressKey,
|
|
19
20
|
typeText,
|
|
@@ -118,6 +119,8 @@ function artifactView(
|
|
|
118
119
|
value: Thread,
|
|
119
120
|
onReply: (id: string, body: string) => void = noop,
|
|
120
121
|
onUpdateAnnotation: (id: string, body: string) => void = noop,
|
|
122
|
+
onAnnotate: ThreadViewProps["onAnnotate"] = noop,
|
|
123
|
+
observer = false,
|
|
121
124
|
) {
|
|
122
125
|
const display = buildDisplay(value.artifact.content);
|
|
123
126
|
|
|
@@ -127,8 +130,8 @@ function artifactView(
|
|
|
127
130
|
display={display}
|
|
128
131
|
marks={marksByDisplay(value.annotations, display)}
|
|
129
132
|
quickActions={[]}
|
|
130
|
-
observer={
|
|
131
|
-
onAnnotate={
|
|
133
|
+
observer={observer}
|
|
134
|
+
onAnnotate={onAnnotate}
|
|
132
135
|
onReply={onReply}
|
|
133
136
|
onUpdateAnnotation={onUpdateAnnotation}
|
|
134
137
|
onExit={noop}
|
|
@@ -1189,3 +1192,57 @@ test("shared viewers see pending activity without owner permission controls", as
|
|
|
1189
1192
|
setup.renderer.destroy();
|
|
1190
1193
|
}
|
|
1191
1194
|
});
|
|
1195
|
+
|
|
1196
|
+
for (const observer of [false, true]) {
|
|
1197
|
+
test(`shared snapshot preserves artifact annotation permissions (observer=${observer})`, async () => {
|
|
1198
|
+
const value = {
|
|
1199
|
+
...thread,
|
|
1200
|
+
agentSnapshot: {
|
|
1201
|
+
...empty,
|
|
1202
|
+
messages: [
|
|
1203
|
+
{
|
|
1204
|
+
id: "answer",
|
|
1205
|
+
role: "agent" as const,
|
|
1206
|
+
text: "Frozen conversation",
|
|
1207
|
+
complete: true,
|
|
1208
|
+
revision: 1,
|
|
1209
|
+
},
|
|
1210
|
+
],
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
const annotations: string[] = [];
|
|
1214
|
+
const setup = await testRender(
|
|
1215
|
+
<AgentThreadPrototype
|
|
1216
|
+
enabled={false}
|
|
1217
|
+
observer={observer}
|
|
1218
|
+
pixelPrototype={false}
|
|
1219
|
+
thread={value}
|
|
1220
|
+
focused
|
|
1221
|
+
theme={DARK}
|
|
1222
|
+
onActiveChange={noop}
|
|
1223
|
+
onOpenFile={noop}
|
|
1224
|
+
>
|
|
1225
|
+
{artifactView(
|
|
1226
|
+
value,
|
|
1227
|
+
noop,
|
|
1228
|
+
noop,
|
|
1229
|
+
(_span, body) => {
|
|
1230
|
+
annotations.push(body);
|
|
1231
|
+
},
|
|
1232
|
+
observer,
|
|
1233
|
+
)}
|
|
1234
|
+
</AgentThreadPrototype>,
|
|
1235
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
try {
|
|
1239
|
+
await waitForText(setup, "Frozen conversation");
|
|
1240
|
+
await dragText(setup, "Original artifact", "Original artifact", "Original artifact".length);
|
|
1241
|
+
await typeText(setup, "Artifact feedback");
|
|
1242
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
1243
|
+
expect(annotations).toEqual(observer ? [] : ["Artifact feedback"]);
|
|
1244
|
+
} finally {
|
|
1245
|
+
setup.renderer.destroy();
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
@@ -398,6 +398,55 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
|
|
|
398
398
|
export function AgentThreadPrototype(
|
|
399
399
|
props: AgentThreadPaneProps & { enabled: boolean; observer: boolean; pixelPrototype: boolean },
|
|
400
400
|
): React.ReactNode {
|
|
401
|
+
if (
|
|
402
|
+
(!props.enabled || props.observer) &&
|
|
403
|
+
props.thread.agentSnapshot &&
|
|
404
|
+
React.isValidElement<ThreadViewProps>(props.children) &&
|
|
405
|
+
props.children.type === ThreadView
|
|
406
|
+
) {
|
|
407
|
+
const child = props.children.props;
|
|
408
|
+
const display =
|
|
409
|
+
child.display ??
|
|
410
|
+
buildDisplay(
|
|
411
|
+
props.thread.artifact.type === "diff"
|
|
412
|
+
? (props.thread.artifact.meta.prBrief ?? "")
|
|
413
|
+
: (props.thread.workingCopy ?? props.thread.artifact.content),
|
|
414
|
+
);
|
|
415
|
+
const projection = projectThreadConversation(
|
|
416
|
+
props.thread,
|
|
417
|
+
props.thread.agentSnapshot,
|
|
418
|
+
display,
|
|
419
|
+
child.marks ?? marksByDisplay(props.thread.annotations, display),
|
|
420
|
+
false,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<ThreadView
|
|
425
|
+
{...child}
|
|
426
|
+
session={projection.session}
|
|
427
|
+
display={projection.display}
|
|
428
|
+
marks={projection.marks}
|
|
429
|
+
canAnnotateBlock={(index) =>
|
|
430
|
+
projection.sources[index]?.kind === "artifact" &&
|
|
431
|
+
(child.canAnnotateBlock?.(index) ?? true)
|
|
432
|
+
}
|
|
433
|
+
isAnnotationReadOnly={(id) =>
|
|
434
|
+
!props.thread.annotations.some((entry) => entry.id === id) ||
|
|
435
|
+
Boolean(child.isAnnotationReadOnly?.(id))
|
|
436
|
+
}
|
|
437
|
+
onAnnotate={(span, body) => {
|
|
438
|
+
if (
|
|
439
|
+
projection.sources[span.start.blockIndex]?.kind !== "artifact" ||
|
|
440
|
+
projection.sources[span.end.blockIndex]?.kind !== "artifact"
|
|
441
|
+
)
|
|
442
|
+
return;
|
|
443
|
+
|
|
444
|
+
return child.onAnnotate(span, body);
|
|
445
|
+
}}
|
|
446
|
+
/>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
401
450
|
if (!props.enabled || props.observer || props.pixelPrototype) return props.children;
|
|
402
451
|
|
|
403
452
|
return <AgentThreadPane {...props} />;
|
|
@@ -8,9 +8,16 @@ import {
|
|
|
8
8
|
SHARE_RECONNECT_MIN_MS,
|
|
9
9
|
type ShareTransport,
|
|
10
10
|
} from "./thread-controller";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
snapshotThreadConversation,
|
|
13
|
+
mergeFromShare,
|
|
14
|
+
type ShareWatchHandlers,
|
|
15
|
+
} from "../integrations/share";
|
|
12
16
|
|
|
13
|
-
const publishShare = mock(async () => ({
|
|
17
|
+
const publishShare = mock(async (_session: Thread) => ({
|
|
18
|
+
line: "ssh p_abc123xy@cueloop.dev",
|
|
19
|
+
copied: true,
|
|
20
|
+
}));
|
|
14
21
|
let remote: Thread;
|
|
15
22
|
const pullShare = mock(async () => remote);
|
|
16
23
|
const pushShare = mock(
|
|
@@ -571,3 +578,41 @@ describe("revoke", () => {
|
|
|
571
578
|
expect(client.sessionDelete).toHaveBeenCalledWith(session.id);
|
|
572
579
|
});
|
|
573
580
|
});
|
|
581
|
+
|
|
582
|
+
test("share publication captures an empty artifact's separate conversation without execution records", async () => {
|
|
583
|
+
const thread = sessionFixture({ artifact: { type: "plan", content: "", meta: {} } });
|
|
584
|
+
const { controller, client } = await connectedController(thread);
|
|
585
|
+
const state = {
|
|
586
|
+
threadId: thread.id,
|
|
587
|
+
phase: { kind: "idle" as const },
|
|
588
|
+
harness: { id: "pi", label: "pi", sessionId: "private-session" },
|
|
589
|
+
messages: [
|
|
590
|
+
{
|
|
591
|
+
id: "answer",
|
|
592
|
+
role: "agent" as const,
|
|
593
|
+
text: "Visible conversation",
|
|
594
|
+
complete: true,
|
|
595
|
+
revision: 1,
|
|
596
|
+
},
|
|
597
|
+
],
|
|
598
|
+
comments: [],
|
|
599
|
+
tools: [],
|
|
600
|
+
promptOperations: [
|
|
601
|
+
{ operationId: "private-operation", fingerprint: "private-hash", result: [] },
|
|
602
|
+
],
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
client.agentGet = async () => state;
|
|
606
|
+
publishShare.mockClear();
|
|
607
|
+
controller.share();
|
|
608
|
+
await tick();
|
|
609
|
+
await tick();
|
|
610
|
+
const shared = publishShare.mock.calls[0]![0];
|
|
611
|
+
|
|
612
|
+
expect(shared.agentSnapshot?.messages).toEqual(state.messages);
|
|
613
|
+
expect(shared.agentSnapshot?.harness).toEqual({ id: "pi", label: "pi" });
|
|
614
|
+
expect(shared.agentSnapshot?.promptOperations).toBeUndefined();
|
|
615
|
+
expect(thread.agentSnapshot).toBeUndefined();
|
|
616
|
+
expect(await snapshotThreadConversation(thread, {})).toBe(thread);
|
|
617
|
+
controller.close();
|
|
618
|
+
});
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
collaboratorAnnotations,
|
|
50
50
|
mergeFromShare,
|
|
51
51
|
publishShare,
|
|
52
|
+
snapshotThreadConversation,
|
|
52
53
|
pullShare,
|
|
53
54
|
pushShare,
|
|
54
55
|
revokeShare,
|
|
@@ -1764,12 +1765,13 @@ class Controller implements ReviewController {
|
|
|
1764
1765
|
}
|
|
1765
1766
|
|
|
1766
1767
|
/** A workbench thread renders live locally; freeze its diff so a remote reviewer gets a stable snapshot. */
|
|
1767
|
-
private freezeForShare(session: Thread): Promise<Thread> {
|
|
1768
|
+
private async freezeForShare(session: Thread): Promise<Thread> {
|
|
1768
1769
|
const repoDiff = this.client?.repoDiff;
|
|
1770
|
+
const shared = repoDiff
|
|
1771
|
+
? await snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
|
|
1772
|
+
: session;
|
|
1769
1773
|
|
|
1770
|
-
return
|
|
1771
|
-
? snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
|
|
1772
|
-
: Promise.resolve(session);
|
|
1774
|
+
return this.client ? snapshotThreadConversation(shared, this.client) : shared;
|
|
1773
1775
|
}
|
|
1774
1776
|
|
|
1775
1777
|
share(): void {
|