@cueloop/client 0.1.0-alpha.102 → 0.1.0-alpha.104
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/annotation-palette.ts +1 -0
- package/src/annotations/components/AnnotationCards.tsx +4 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
- package/src/annotations/discussions.ts +1 -0
- package/src/annotations/thread-selection.ts +6 -0
- package/src/annotations/use-annotation-surface.tsx +97 -0
- package/src/app/App.deleted-scroll.test.tsx +3 -0
- package/src/app/App.harness-settings.test.tsx +77 -0
- package/src/app/App.plan-render.test.tsx +2 -0
- package/src/app/App.plan-review.test.tsx +1 -0
- package/src/app/App.readonly.test.tsx +63 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.tsx +94 -12
- package/src/app/App.visual.test.tsx +1 -0
- package/src/app/App.walk.test.tsx +1 -0
- package/src/app/App.workbench.test.tsx +1 -0
- package/src/app/app-screens.tsx +3 -2
- package/src/app/app-view-model.ts +11 -1
- package/src/app/components/WorkspacePanels.tsx +1 -0
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/intent-dispatch.ts +26 -0
- package/src/app/ready-signal.ts +2 -0
- package/src/app/run.ts +2 -0
- package/src/app/serve.test.ts +4 -0
- package/src/diff/components/AnnotatableFileView.tsx +1 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +25 -0
- package/src/diff/components/diff-content-view-layout.ts +3 -0
- package/src/diff/diff-fold.ts +4 -0
- package/src/diff/diff-hunk-curate.ts +3 -0
- package/src/diff/diff-intraline.ts +6 -0
- package/src/diff/diff-syntax.ts +8 -0
- package/src/diff/split-diff.ts +6 -0
- package/src/diff/view-diff.ts +18 -0
- package/src/diff/walk.ts +3 -0
- package/src/index.ts +1 -0
- package/src/integrations/attribution.ts +2 -0
- package/src/integrations/client-extension-registry.ts +4 -0
- package/src/integrations/clipboard.ts +1 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
- package/src/integrations/components/ShareDialog.tsx +28 -0
- package/src/integrations/editor.ts +3 -0
- package/src/integrations/github-identity.test.ts +1 -0
- package/src/integrations/github-identity.ts +1 -0
- package/src/integrations/herdr.ts +2 -0
- package/src/integrations/pasted-image.ts +2 -0
- package/src/integrations/share.ts +46 -1
- package/src/keyboard/components/HandleChipsInput.tsx +4 -0
- package/src/keyboard/components/SlashComposer.tsx +3 -0
- package/src/keyboard/key-bindings.ts +3 -0
- package/src/keyboard/keymap.ts +51 -0
- package/src/keyboard/skills.ts +2 -0
- package/src/keyboard/slash-palette.ts +8 -0
- package/src/keyboard/thread-chords.ts +5 -0
- package/src/keyboard/use-shared-keyboard.ts +1 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
- package/src/markdown/components/ThreadView.tsx +13 -0
- package/src/markdown/mark-runs.ts +4 -0
- package/src/markdown/markdown-editor-syntax-style.ts +1 -0
- package/src/markdown/markdown-grid.ts +6 -0
- package/src/markdown/markdown-highlight.ts +1 -0
- package/src/markdown/thread-code-syntax.ts +3 -0
- package/src/markdown/view-plan.ts +52 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/prototype/components/PrototypeContentView.tsx +17 -0
- package/src/prototype/prototype-browser.ts +11 -0
- package/src/settings/components/SettingsDialog.tsx +3 -0
- package/src/settings/config.test.ts +79 -1
- package/src/settings/config.ts +91 -1
- package/src/settings/use-settings-dialog.test.tsx +172 -0
- package/src/settings/use-settings-dialog.tsx +115 -1
- package/src/settings/use-thread-harness-setting.test.tsx +87 -0
- package/src/settings/use-thread-harness-setting.ts +86 -0
- package/src/stories/stories.test.tsx +4 -0
- package/src/stories/story.ts +6 -0
- package/src/terminal/ansi-screen.ts +5 -0
- package/src/terminal/components/terminal-pane.ts +12 -0
- package/src/terminal/ghostty-terminal.ts +10 -0
- package/src/terminal/kitty-image.ts +3 -0
- package/src/terminal/pty.ts +9 -0
- package/src/testing/test-support.test.ts +2 -0
- package/src/testing/test-support.ts +16 -0
- package/src/thread/agent-continuation.test.ts +2 -0
- package/src/thread/agent-transcript.ts +7 -0
- package/src/thread/components/AgentConfigControls.test.tsx +41 -0
- package/src/thread/components/AgentConfigControls.tsx +8 -1
- package/src/thread/components/AgentThreadPane.test.tsx +91 -3
- package/src/thread/components/AgentThreadPane.tsx +61 -0
- package/src/thread/components/ThreadTree.tsx +1 -0
- package/src/thread/components/thread-tree.test.ts +1 -0
- package/src/thread/thread-controller.curate.test.ts +2 -0
- package/src/thread/thread-controller.share.test.ts +47 -2
- package/src/thread/thread-controller.ts +129 -4
- package/src/thread/tree-view.ts +4 -0
- package/src/thread/use-thread-agent.test.tsx +1 -0
- package/src/thread/use-thread-agent.ts +5 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
- package/src/ui/components/OverlayScrollbar.tsx +5 -0
- package/src/ui/components/PromptDialog.tsx +2 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/ScrollArea.tsx +2 -0
- package/src/ui/components/primitives/Button.tsx +1 -0
- package/src/ui/components/primitives/Tabs.tsx +1 -0
- package/src/ui/components/primitives/Tree.tsx +4 -0
- package/src/ui/components/primitives/tree-model.ts +7 -0
- package/src/ui/components/syntax-highlight.ts +5 -0
- package/src/ui/components/truncate-title.ts +1 -0
- package/src/ui/use-frame-measure.ts +3 -0
- package/src/ui/use-terminal-virtualizer.ts +6 -0
- package/src/workbench/components/ChangesColumn.tsx +7 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
- package/src/workbench/components/EditorGrid.tsx +8 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +2 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +8 -0
- package/src/workbench/components/TreePane.tsx +2 -0
- package/src/workbench/components/editor-grid.ts +3 -0
- package/src/workbench/components/file-tree.ts +2 -0
- package/src/workbench/use-changes-workbench.ts +5 -0
- package/src/workbench/use-remember-layout.ts +2 -0
- package/src/workbench/use-repo-changes.ts +2 -0
|
@@ -73,9 +73,11 @@ test("deleted file caret scrolls one row and stays at the file boundaries", asyn
|
|
|
73
73
|
const box = findScrollbox(setup.renderer.root);
|
|
74
74
|
|
|
75
75
|
if (!box) throw new Error("file diff scrollbox missing");
|
|
76
|
+
|
|
76
77
|
for (let offset = 0; offset < 1000; offset += 20) {
|
|
77
78
|
box.scrollTo({ x: 0, y: offset });
|
|
78
79
|
await settle(setup);
|
|
80
|
+
|
|
79
81
|
if (setup.captureCharFrame().includes("row 130")) break;
|
|
80
82
|
}
|
|
81
83
|
const target = locateText(setup, "row 130");
|
|
@@ -127,6 +129,7 @@ test("deleted file caret scrolls one row and stays at the file boundaries", asyn
|
|
|
127
129
|
.findIndex((line) => line.includes("▎"));
|
|
128
130
|
|
|
129
131
|
if (marker === box.viewport.screenY) anchored = true;
|
|
132
|
+
|
|
130
133
|
if (anchored && box.scrollTop > 0) expect(marker).toBe(box.viewport.screenY);
|
|
131
134
|
}
|
|
132
135
|
expect(anchored).toBe(true);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
7
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
8
|
+
import { App } from "./App";
|
|
9
|
+
import {
|
|
10
|
+
clickText,
|
|
11
|
+
isolateUserConfig,
|
|
12
|
+
isolatedUserConfigPath,
|
|
13
|
+
renderReadyApp,
|
|
14
|
+
waitForText,
|
|
15
|
+
waitForTextGone,
|
|
16
|
+
} from "../testing/test-support";
|
|
17
|
+
|
|
18
|
+
test("repeated harness toggle failures show feedback without losing the controller binding", async () => {
|
|
19
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-harness-settings-"));
|
|
20
|
+
const restore = isolateUserConfig(home);
|
|
21
|
+
const server = new DaemonServer({ home, idleExitMs: 0 });
|
|
22
|
+
|
|
23
|
+
writeFileSync(isolatedUserConfigPath(home), "[experimental]\nthread_agent = true\n");
|
|
24
|
+
server.start();
|
|
25
|
+
const thread = server.core.sessionCreate({
|
|
26
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
27
|
+
artifact: { type: "plan", content: "# Switching review", meta: { title: "Switching review" } },
|
|
28
|
+
});
|
|
29
|
+
const state: ThreadAgentState = {
|
|
30
|
+
threadId: thread.id,
|
|
31
|
+
phase: { kind: "idle" },
|
|
32
|
+
messages: [],
|
|
33
|
+
comments: [],
|
|
34
|
+
tools: [],
|
|
35
|
+
};
|
|
36
|
+
let switches = 0;
|
|
37
|
+
const setup = await renderReadyApp(
|
|
38
|
+
<App
|
|
39
|
+
home={home}
|
|
40
|
+
sessionId={thread.id}
|
|
41
|
+
agentClient={{
|
|
42
|
+
agentGet: async () => state,
|
|
43
|
+
agentPrompt: async () => state,
|
|
44
|
+
agentCancel: async () => state,
|
|
45
|
+
agentComment: async () => state,
|
|
46
|
+
agentPermission: async () => state,
|
|
47
|
+
agentConfigure: async (params) => {
|
|
48
|
+
if (!params.configId) return state;
|
|
49
|
+
|
|
50
|
+
switches++;
|
|
51
|
+
throw new Error(`Switch failure ${switches}`);
|
|
52
|
+
},
|
|
53
|
+
}}
|
|
54
|
+
/>,
|
|
55
|
+
{ width: 120, height: 32 },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await setup.mockMouse.click(1, 0);
|
|
60
|
+
await waitForText(setup, "settings");
|
|
61
|
+
await clickText(setup, "settings");
|
|
62
|
+
await waitForText(setup, "General");
|
|
63
|
+
await clickText(setup, "Thread ");
|
|
64
|
+
await waitForText(setup, "Harness");
|
|
65
|
+
await clickText(setup, "Harness");
|
|
66
|
+
await waitForText(setup, "Switch failure 1");
|
|
67
|
+
await waitForTextGone(setup, "Switch failure 1");
|
|
68
|
+
await clickText(setup, "Harness");
|
|
69
|
+
await waitForText(setup, "Switch failure 2");
|
|
70
|
+
expect(switches).toBe(2);
|
|
71
|
+
} finally {
|
|
72
|
+
setup.renderer.destroy();
|
|
73
|
+
server.stop();
|
|
74
|
+
restore();
|
|
75
|
+
rmSync(home, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
@@ -91,6 +91,7 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
|
91
91
|
for (const line of setup.captureSpans().lines) {
|
|
92
92
|
for (const span of line.spans) {
|
|
93
93
|
if (span.fg === undefined || !span.text.includes(needle)) continue;
|
|
94
|
+
|
|
94
95
|
const [red, green, blue] = span.fg.toInts();
|
|
95
96
|
|
|
96
97
|
foregrounds.push(
|
|
@@ -109,6 +110,7 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
|
109
110
|
for (const line of setup.captureSpans().lines) {
|
|
110
111
|
for (const span of line.spans) {
|
|
111
112
|
if (!span.text.includes(needle)) continue;
|
|
113
|
+
|
|
112
114
|
const [red, green, blue] = span.bg.toInts();
|
|
113
115
|
|
|
114
116
|
backgrounds.push(
|
|
@@ -84,6 +84,7 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
|
84
84
|
for (const line of setup.captureSpans().lines) {
|
|
85
85
|
for (const span of line.spans) {
|
|
86
86
|
if (!span.text.includes(needle)) continue;
|
|
87
|
+
|
|
87
88
|
const [red, green, blue] = span.bg.toInts();
|
|
88
89
|
|
|
89
90
|
backgrounds.push(
|
|
@@ -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.test.tsx
CHANGED
|
@@ -78,6 +78,7 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
|
78
78
|
for (const line of setup.captureSpans().lines) {
|
|
79
79
|
for (const span of line.spans) {
|
|
80
80
|
if (!span.text.includes(needle)) continue;
|
|
81
|
+
|
|
81
82
|
const [red, green, blue] = span.fg.toInts();
|
|
82
83
|
|
|
83
84
|
foregrounds.push(
|
package/src/app/App.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { THREAD_MODEL_DEFAULTS } from "@cueloop/schema";
|
|
2
|
+
import { useThreadHarnessSetting } from "../settings/use-thread-harness-setting";
|
|
1
3
|
import React, {
|
|
2
4
|
useCallback,
|
|
3
5
|
useEffect,
|
|
@@ -203,6 +205,7 @@ export function quitKeyHandled(
|
|
|
203
205
|
onExit?: (code: number) => void,
|
|
204
206
|
): boolean {
|
|
205
207
|
if (!key.ctrl || key.name !== "q") return false;
|
|
208
|
+
|
|
206
209
|
onExit?.(0);
|
|
207
210
|
|
|
208
211
|
return true;
|
|
@@ -214,6 +217,7 @@ function menuModalHandled(
|
|
|
214
217
|
key: { name: string },
|
|
215
218
|
): boolean {
|
|
216
219
|
if (menuControl.openMenuId === null) return false;
|
|
220
|
+
|
|
217
221
|
if (key.name === "escape") menuControl.closeMenu();
|
|
218
222
|
|
|
219
223
|
return true;
|
|
@@ -300,7 +304,9 @@ function chooseThreadBody(choice: {
|
|
|
300
304
|
threadView: React.ReactNode;
|
|
301
305
|
}): React.ReactNode {
|
|
302
306
|
if (choice.isPixelPrototype) return choice.prototype;
|
|
307
|
+
|
|
303
308
|
if (choice.isDiff) return choice.diffPlaceholder;
|
|
309
|
+
|
|
304
310
|
if (choice.editingBody) return choice.editor;
|
|
305
311
|
|
|
306
312
|
return choice.threadView;
|
|
@@ -453,16 +459,19 @@ export function threadsNavHandled(params: {
|
|
|
453
459
|
const { focusedPane, quiet, key, count } = params;
|
|
454
460
|
|
|
455
461
|
if (focusedPane !== "threads" || !quiet || count === 0) return false;
|
|
462
|
+
|
|
456
463
|
if (key.name === "j" || key.name === "down") {
|
|
457
464
|
params.setInboxCursor((cursor) => Math.min(cursor + 1, count - 1));
|
|
458
465
|
|
|
459
466
|
return true;
|
|
460
467
|
}
|
|
468
|
+
|
|
461
469
|
if (key.name === "k" || key.name === "up") {
|
|
462
470
|
params.setInboxCursor((cursor) => Math.max(cursor - 1, 0));
|
|
463
471
|
|
|
464
472
|
return true;
|
|
465
473
|
}
|
|
474
|
+
|
|
466
475
|
if (key.name === "return" || key.name === "enter") {
|
|
467
476
|
params.openSession();
|
|
468
477
|
|
|
@@ -490,8 +499,11 @@ export function visiblePanes(
|
|
|
490
499
|
const panes: FocusPane[] = [];
|
|
491
500
|
|
|
492
501
|
if (sidebarOpen) panes.push("threads");
|
|
502
|
+
|
|
493
503
|
if (threadShown) panes.push("thread");
|
|
504
|
+
|
|
494
505
|
if (changesOpen) panes.push("changes");
|
|
506
|
+
|
|
495
507
|
if (projectOpen) panes.push("project");
|
|
496
508
|
|
|
497
509
|
return panes;
|
|
@@ -503,6 +515,7 @@ export function nextFocusPane(
|
|
|
503
515
|
backward: boolean,
|
|
504
516
|
): FocusPane {
|
|
505
517
|
if (panes.length === 0) return current;
|
|
518
|
+
|
|
506
519
|
const index = panes.indexOf(current);
|
|
507
520
|
const step = backward ? -1 : 1;
|
|
508
521
|
|
|
@@ -547,7 +560,11 @@ export function App({
|
|
|
547
560
|
servedArtifact,
|
|
548
561
|
extensionRegistry,
|
|
549
562
|
}: AppProps): React.ReactNode {
|
|
550
|
-
const { observer, isOwner } = computeRoleCapabilities(
|
|
563
|
+
const { observer, isOwner, canManageThreads } = computeRoleCapabilities(
|
|
564
|
+
readOnly,
|
|
565
|
+
role,
|
|
566
|
+
isSharedThreadConnection(openClient, shareTransport),
|
|
567
|
+
);
|
|
551
568
|
const controller = useMemo(
|
|
552
569
|
() =>
|
|
553
570
|
createReviewController({
|
|
@@ -617,6 +634,7 @@ export function App({
|
|
|
617
634
|
|
|
618
635
|
if (next.has(id)) next.delete(id);
|
|
619
636
|
else next.add(id);
|
|
637
|
+
|
|
620
638
|
persistPins([...next]);
|
|
621
639
|
|
|
622
640
|
return next;
|
|
@@ -673,6 +691,7 @@ export function App({
|
|
|
673
691
|
const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
|
|
674
692
|
const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
|
|
675
693
|
const [skills, setSkills] = useState<SlashItem[]>([]);
|
|
694
|
+
const [threadModelDefaults, setThreadModelDefaults] = useState(THREAD_MODEL_DEFAULTS);
|
|
676
695
|
const [reviewSkill, setReviewSkill] = useState("code-review");
|
|
677
696
|
const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
|
|
678
697
|
const paletteNames = useMemo(
|
|
@@ -688,6 +707,8 @@ export function App({
|
|
|
688
707
|
|
|
689
708
|
setPrototypePixels(config.experimental.prototypePixels);
|
|
690
709
|
setThreadAgentEnabled(config.experimental.threadAgent);
|
|
710
|
+
setThreadHarness(config.thread.harness);
|
|
711
|
+
setThreadModelDefaults({ pi: config.thread.pi, fx: config.thread.fx });
|
|
691
712
|
keysRef.current = config.keys;
|
|
692
713
|
keyBindings.setKeys(config.keys);
|
|
693
714
|
setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
|
|
@@ -714,6 +735,7 @@ export function App({
|
|
|
714
735
|
|
|
715
736
|
useEffect(() => {
|
|
716
737
|
if (!toast) return;
|
|
738
|
+
|
|
717
739
|
const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
|
|
718
740
|
|
|
719
741
|
return () => clearTimeout(timer);
|
|
@@ -725,6 +747,7 @@ export function App({
|
|
|
725
747
|
|
|
726
748
|
useEffect(() => {
|
|
727
749
|
if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
|
|
750
|
+
|
|
728
751
|
promptedSelfRef.current = true;
|
|
729
752
|
const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
|
|
730
753
|
|
|
@@ -747,13 +770,31 @@ export function App({
|
|
|
747
770
|
|
|
748
771
|
void resolveGithubIdentity().then((github) => {
|
|
749
772
|
if (identityGenerationRef.current !== generation) return;
|
|
773
|
+
|
|
750
774
|
if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
|
|
775
|
+
|
|
751
776
|
const name = github.name?.trim() || github.login;
|
|
752
777
|
|
|
753
778
|
applyIdentity({ name, provider: "github" });
|
|
754
779
|
});
|
|
755
780
|
};
|
|
756
781
|
|
|
782
|
+
const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
|
|
783
|
+
const [threadHarness, setThreadHarness] = useState<"pi" | "fx">("pi");
|
|
784
|
+
|
|
785
|
+
const harnessSetting = useThreadHarnessSetting({
|
|
786
|
+
home,
|
|
787
|
+
thread: session,
|
|
788
|
+
enabled: threadAgentEnabled,
|
|
789
|
+
owner: isOwner,
|
|
790
|
+
state: agentState,
|
|
791
|
+
preferred: threadHarness,
|
|
792
|
+
client: agentClient,
|
|
793
|
+
onState: setAgentState,
|
|
794
|
+
onPreferred: setThreadHarness,
|
|
795
|
+
onError: (message) => controller.setStatus(message),
|
|
796
|
+
});
|
|
797
|
+
|
|
757
798
|
// ── settings dialog: config-backed model, navigation, persistence ──
|
|
758
799
|
const {
|
|
759
800
|
settingsNav,
|
|
@@ -764,6 +805,8 @@ export function App({
|
|
|
764
805
|
onCategorySelect,
|
|
765
806
|
openSettings,
|
|
766
807
|
} = useSettingsDialog({
|
|
808
|
+
threadDefaults: threadModelDefaults,
|
|
809
|
+
setThreadDefaults: setThreadModelDefaults,
|
|
767
810
|
theme,
|
|
768
811
|
appearance,
|
|
769
812
|
autoClose,
|
|
@@ -787,6 +830,9 @@ export function App({
|
|
|
787
830
|
reviewSkill,
|
|
788
831
|
reviewWorkspace,
|
|
789
832
|
setReviewWorkspace,
|
|
833
|
+
canSwitchHarness: harnessSetting.enabled,
|
|
834
|
+
threadHarness: harnessSetting.harness,
|
|
835
|
+
onSwitchHarness: harnessSetting.switchHarness,
|
|
790
836
|
});
|
|
791
837
|
|
|
792
838
|
// ── derived view model ──────────────────────
|
|
@@ -843,7 +889,6 @@ export function App({
|
|
|
843
889
|
() => undefined,
|
|
844
890
|
);
|
|
845
891
|
const agentInvoke = useRef<((commentId?: string) => void) | undefined>(undefined);
|
|
846
|
-
const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
|
|
847
892
|
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
848
893
|
const [welcomeComposing, setWelcomeComposing] = useState(false);
|
|
849
894
|
// inline body edit: the markdown editor owns the thread pane and all keys while open
|
|
@@ -868,9 +913,11 @@ export function App({
|
|
|
868
913
|
|
|
869
914
|
const openCardEdit = (annotationId: string): void => {
|
|
870
915
|
if (observer) return controller.setStatus("observer - read-only");
|
|
916
|
+
|
|
871
917
|
const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
|
|
872
918
|
|
|
873
919
|
if (!annotation) return;
|
|
920
|
+
|
|
874
921
|
// a collaborator's note is theirs to word: activating it (click or e) renames
|
|
875
922
|
// the author rather than editing the body the planner does not own
|
|
876
923
|
if (annotation.author) {
|
|
@@ -882,7 +929,9 @@ export function App({
|
|
|
882
929
|
text: authorNames[annotation.author] ?? "",
|
|
883
930
|
});
|
|
884
931
|
}
|
|
932
|
+
|
|
885
933
|
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
934
|
+
|
|
886
935
|
liveInput.current = annotation.body;
|
|
887
936
|
setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
|
|
888
937
|
};
|
|
@@ -894,6 +943,7 @@ export function App({
|
|
|
894
943
|
|
|
895
944
|
return;
|
|
896
945
|
}
|
|
946
|
+
|
|
897
947
|
renderer?.suspend();
|
|
898
948
|
try {
|
|
899
949
|
controller.edit();
|
|
@@ -949,6 +999,7 @@ export function App({
|
|
|
949
999
|
},
|
|
950
1000
|
openShareDialog: () => {
|
|
951
1001
|
if (!isOwner) return controller.setStatus("only the plan owner can share");
|
|
1002
|
+
|
|
952
1003
|
shareDialogStore.getState().reset();
|
|
953
1004
|
setShareDialogOpen(true);
|
|
954
1005
|
},
|
|
@@ -974,6 +1025,7 @@ export function App({
|
|
|
974
1025
|
useEffect(() => {
|
|
975
1026
|
// the bare shell drives its panes from its own workbench, so only reconcile against this one
|
|
976
1027
|
if (!session) return;
|
|
1028
|
+
|
|
977
1029
|
const next = reconciledFocus(navigablePanes, focusedPane);
|
|
978
1030
|
|
|
979
1031
|
if (next) setFocusedPane(next);
|
|
@@ -995,6 +1047,7 @@ export function App({
|
|
|
995
1047
|
});
|
|
996
1048
|
|
|
997
1049
|
if (!intent) return false;
|
|
1050
|
+
|
|
998
1051
|
if (intent.type === "cut" && selection) {
|
|
999
1052
|
controller.cut(
|
|
1000
1053
|
selection.start.blockIndex,
|
|
@@ -1010,9 +1063,11 @@ export function App({
|
|
|
1010
1063
|
const openThread = (id: string): void => {
|
|
1011
1064
|
// save the open editor into the leaving thread's working copy before the switch, so a click away never drops edits
|
|
1012
1065
|
if (bodyEditing.editing) bodyEditing.requestExit();
|
|
1066
|
+
|
|
1013
1067
|
const index = grouped.ordered.findIndex((thread) => thread.id === id);
|
|
1014
1068
|
|
|
1015
1069
|
if (index >= 0) setInboxCursor(() => index);
|
|
1070
|
+
|
|
1016
1071
|
controller.open(id);
|
|
1017
1072
|
};
|
|
1018
1073
|
|
|
@@ -1038,7 +1093,9 @@ export function App({
|
|
|
1038
1093
|
!key.super
|
|
1039
1094
|
)
|
|
1040
1095
|
return true;
|
|
1096
|
+
|
|
1041
1097
|
if (!threadViewActive || threadViewSuspended) return false;
|
|
1098
|
+
|
|
1042
1099
|
if (!threadComposing) {
|
|
1043
1100
|
const chord = resolveSessionChord(key, { isOwner, resolved });
|
|
1044
1101
|
|
|
@@ -1050,14 +1107,19 @@ export function App({
|
|
|
1050
1107
|
|
|
1051
1108
|
useKeyboard((key) => {
|
|
1052
1109
|
if (quitKeyHandled(key, onExit)) return;
|
|
1110
|
+
|
|
1053
1111
|
// the inline body editor owns the pane and every key while open
|
|
1054
1112
|
if (bodyEditing.editing) return;
|
|
1113
|
+
|
|
1055
1114
|
// the share dialog owns its own keys while open; the shell grammar stands down
|
|
1056
1115
|
if (shareDialogOpen) return;
|
|
1116
|
+
|
|
1057
1117
|
if (menuModalHandled(menuControl, key)) return;
|
|
1118
|
+
|
|
1058
1119
|
if (paneCycleRequested(key, overlay, menuOwnsKeyboard, threadComposing)) {
|
|
1059
1120
|
return cyclePanes(Boolean(key.shift));
|
|
1060
1121
|
}
|
|
1122
|
+
|
|
1061
1123
|
if (
|
|
1062
1124
|
threadsNavHandled({
|
|
1063
1125
|
focusedPane,
|
|
@@ -1069,17 +1131,24 @@ export function App({
|
|
|
1069
1131
|
})
|
|
1070
1132
|
)
|
|
1071
1133
|
return;
|
|
1134
|
+
|
|
1072
1135
|
if (threadSurfaceHandledKey(key)) return;
|
|
1136
|
+
|
|
1073
1137
|
// A compose textarea owns the keyboard while open: let it receive the typed note instead of the
|
|
1074
1138
|
// global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
|
|
1075
1139
|
if (prototypeComposing || welcomeComposing) return;
|
|
1140
|
+
|
|
1076
1141
|
if (menuDialog === "settings") return void handleSettingsKey(key.name);
|
|
1142
|
+
|
|
1077
1143
|
if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
|
|
1144
|
+
|
|
1078
1145
|
// the toast is non-modal: escape only dismisses it when nothing else owns
|
|
1079
1146
|
// escape, so an open overlay (compose, submit, prompt, walk) still cancels
|
|
1080
1147
|
if (toastDismissRequested(toast !== null, key, overlay, mode.type === "span"))
|
|
1081
1148
|
return controller.dismissToast();
|
|
1149
|
+
|
|
1082
1150
|
if (bareShellDefersKeys(session === null, overlay === "none", focusedPane)) return;
|
|
1151
|
+
|
|
1083
1152
|
const state = buildKeyState({
|
|
1084
1153
|
keys: keysRef.current,
|
|
1085
1154
|
observer,
|
|
@@ -1136,6 +1205,7 @@ export function App({
|
|
|
1136
1205
|
|
|
1137
1206
|
// ── render ──────────────────────────────────
|
|
1138
1207
|
if (error) return <ErrorScreen error={error} theme={theme} />;
|
|
1208
|
+
|
|
1139
1209
|
if (!session)
|
|
1140
1210
|
return inbox ? (
|
|
1141
1211
|
<SlashSkillsContext.Provider value={skills}>
|
|
@@ -1160,8 +1230,10 @@ export function App({
|
|
|
1160
1230
|
onFocusPane={setFocusedPane}
|
|
1161
1231
|
menuControl={menuControl}
|
|
1162
1232
|
pinnedIds={pinnedIds}
|
|
1163
|
-
onPin={togglePin}
|
|
1164
|
-
onRename={(
|
|
1233
|
+
onPin={enabledThreadAction(canManageThreads, togglePin)}
|
|
1234
|
+
onRename={enabledThreadAction(canManageThreads, (id: string, title: string) =>
|
|
1235
|
+
setMode({ type: "renameThread", sessionId: id, text: title }),
|
|
1236
|
+
)}
|
|
1165
1237
|
quickActions={quickActions}
|
|
1166
1238
|
diffView={diffView}
|
|
1167
1239
|
onWelcomeComposingChange={setWelcomeComposing}
|
|
@@ -1229,15 +1301,20 @@ export function App({
|
|
|
1229
1301
|
// A share viewer/observer has no Edit affordance (the button is hidden), so
|
|
1230
1302
|
// this is owner-only; stay silent rather than nag if it is ever reached.
|
|
1231
1303
|
if (!isOwner) return;
|
|
1304
|
+
|
|
1232
1305
|
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
1306
|
+
|
|
1233
1307
|
openBodyEditor();
|
|
1234
1308
|
};
|
|
1235
1309
|
|
|
1236
1310
|
// clicking the rail Submit button: same read-only answer as the submit key
|
|
1237
1311
|
const onSubmitRequest = (): void => {
|
|
1238
1312
|
if (observer) return controller.setStatus("observer - read-only");
|
|
1313
|
+
|
|
1239
1314
|
if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
|
|
1315
|
+
|
|
1240
1316
|
if (resolved) return;
|
|
1317
|
+
|
|
1241
1318
|
dispatch({ type: "openSubmit" });
|
|
1242
1319
|
};
|
|
1243
1320
|
|
|
@@ -1285,13 +1362,15 @@ export function App({
|
|
|
1285
1362
|
width={30}
|
|
1286
1363
|
onSelect={openThread}
|
|
1287
1364
|
onCreateThread={createEmptyThread}
|
|
1288
|
-
onPin={togglePin}
|
|
1289
|
-
onRename={(id, title) =>
|
|
1290
|
-
setMode({ type: "renameThread", sessionId: id, text: title })
|
|
1291
|
-
}
|
|
1292
|
-
onRequestDelete={(
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
+
)}
|
|
1295
1374
|
theme={theme}
|
|
1296
1375
|
/>
|
|
1297
1376
|
</scrollbox>
|
|
@@ -1618,7 +1697,10 @@ export function App({
|
|
|
1618
1697
|
);
|
|
1619
1698
|
}
|
|
1620
1699
|
|
|
1621
|
-
function enabledThreadAction
|
|
1700
|
+
function enabledThreadAction<Arguments extends unknown[]>(
|
|
1701
|
+
enabled: boolean,
|
|
1702
|
+
action: (...args: Arguments) => void,
|
|
1703
|
+
): ((...args: Arguments) => void) | undefined {
|
|
1622
1704
|
return enabled ? action : undefined;
|
|
1623
1705
|
}
|
|
1624
1706
|
|
|
@@ -32,6 +32,7 @@ function hasSpanColor(
|
|
|
32
32
|
for (const line of setup.captureSpans().lines) {
|
|
33
33
|
for (const span of line.spans) {
|
|
34
34
|
if (!span.text.includes(needle) || !span.fg) continue;
|
|
35
|
+
|
|
35
36
|
const [red, green, blue] = span.fg.toInts();
|
|
36
37
|
|
|
37
38
|
if (red === color[0] && green === color[1] && blue === color[2]) return true;
|
|
@@ -88,6 +88,7 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
|
88
88
|
for (const line of setup.captureSpans().lines) {
|
|
89
89
|
for (const span of line.spans) {
|
|
90
90
|
if (!span.text.includes(needle)) continue;
|
|
91
|
+
|
|
91
92
|
const [red, green, blue] = span.fg.toInts();
|
|
92
93
|
|
|
93
94
|
foregrounds.push(
|
|
@@ -244,6 +244,7 @@ describe("the four-pane workbench", () => {
|
|
|
244
244
|
for (const [index, direction] of Array.from({ length: 500 }, () => "down" as const).entries()) {
|
|
245
245
|
// eslint-disable-next-line no-await-in-loop
|
|
246
246
|
await setup.mockMouse.scroll(75, 10, direction);
|
|
247
|
+
|
|
247
248
|
if (index % 25 === 0) {
|
|
248
249
|
// eslint-disable-next-line no-await-in-loop
|
|
249
250
|
await setup.waitForVisualIdle();
|
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;
|
|
@@ -395,6 +395,7 @@ export function NoThreadShell(props: {
|
|
|
395
395
|
message={confirming ? `Delete "${confirming.title}"? This removes the thread.` : ""}
|
|
396
396
|
onConfirm={() => {
|
|
397
397
|
if (confirming) controller.deleteSession(confirming.sessionId);
|
|
398
|
+
|
|
398
399
|
setMode({ type: "normal" });
|
|
399
400
|
}}
|
|
400
401
|
onCancel={() => setMode({ type: "normal" })}
|
|
@@ -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) {
|
|
@@ -49,8 +52,11 @@ export function resolveOverlay(
|
|
|
49
52
|
walking: boolean,
|
|
50
53
|
): KeyState["overlay"] {
|
|
51
54
|
if (mode.type === "compose" || mode.type === "railEdit") return "compose";
|
|
55
|
+
|
|
52
56
|
if (mode.type === "submit") return "submit";
|
|
57
|
+
|
|
53
58
|
if (mode.type === "confirmDelete") return "confirm";
|
|
59
|
+
|
|
54
60
|
if (
|
|
55
61
|
mode.type === "rename" ||
|
|
56
62
|
mode.type === "renameThread" ||
|
|
@@ -58,9 +64,13 @@ export function resolveOverlay(
|
|
|
58
64
|
mode.type === "treePrompt"
|
|
59
65
|
)
|
|
60
66
|
return "prompt";
|
|
67
|
+
|
|
61
68
|
if (mode.type === "spanActions") return "spanActions";
|
|
69
|
+
|
|
62
70
|
if (completionPhase === "prompt") return "completion-prompt";
|
|
71
|
+
|
|
63
72
|
if (completionPhase === "counting") return "completion-counting";
|
|
73
|
+
|
|
64
74
|
if (walking) return "walk";
|
|
65
75
|
|
|
66
76
|
return "none";
|
|
@@ -53,6 +53,7 @@ export function WorkspacePanels(props: {
|
|
|
53
53
|
|
|
54
54
|
useKeyboard((key) => {
|
|
55
55
|
if (!props.focused || (key.name !== "[" && key.name !== "]")) return;
|
|
56
|
+
|
|
56
57
|
const choices = [null, ...views];
|
|
57
58
|
const current = selected ? views.indexOf(selected) + 1 : 0;
|
|
58
59
|
const step = key.name === "]" ? 1 : -1;
|
|
@@ -194,6 +194,7 @@ describe("move", () => {
|
|
|
194
194
|
const advance = setCursor.mock.calls[0]![0];
|
|
195
195
|
|
|
196
196
|
if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
|
|
197
|
+
|
|
197
198
|
expect(advance(2)).toBe(2); // already at the end, stays
|
|
198
199
|
expect(advance(0)).toBe(1);
|
|
199
200
|
});
|