@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.
Files changed (126) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +4 -0
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +97 -0
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.readonly.test.tsx +63 -0
  13. package/src/app/App.test.tsx +1 -0
  14. package/src/app/App.tsx +94 -12
  15. package/src/app/App.visual.test.tsx +1 -0
  16. package/src/app/App.walk.test.tsx +1 -0
  17. package/src/app/App.workbench.test.tsx +1 -0
  18. package/src/app/app-screens.tsx +3 -2
  19. package/src/app/app-view-model.ts +11 -1
  20. package/src/app/components/WorkspacePanels.tsx +1 -0
  21. package/src/app/intent-dispatch.test.ts +1 -0
  22. package/src/app/intent-dispatch.ts +26 -0
  23. package/src/app/ready-signal.ts +2 -0
  24. package/src/app/run.ts +2 -0
  25. package/src/app/serve.test.ts +4 -0
  26. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  27. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  28. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  29. package/src/diff/components/DiffContentView.tsx +25 -0
  30. package/src/diff/components/diff-content-view-layout.ts +3 -0
  31. package/src/diff/diff-fold.ts +4 -0
  32. package/src/diff/diff-hunk-curate.ts +3 -0
  33. package/src/diff/diff-intraline.ts +6 -0
  34. package/src/diff/diff-syntax.ts +8 -0
  35. package/src/diff/split-diff.ts +6 -0
  36. package/src/diff/view-diff.ts +18 -0
  37. package/src/diff/walk.ts +3 -0
  38. package/src/index.ts +1 -0
  39. package/src/integrations/attribution.ts +2 -0
  40. package/src/integrations/client-extension-registry.ts +4 -0
  41. package/src/integrations/clipboard.ts +1 -0
  42. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  43. package/src/integrations/components/ShareDialog.tsx +28 -0
  44. package/src/integrations/editor.ts +3 -0
  45. package/src/integrations/github-identity.test.ts +1 -0
  46. package/src/integrations/github-identity.ts +1 -0
  47. package/src/integrations/herdr.ts +2 -0
  48. package/src/integrations/pasted-image.ts +2 -0
  49. package/src/integrations/share.ts +46 -1
  50. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  51. package/src/keyboard/components/SlashComposer.tsx +3 -0
  52. package/src/keyboard/key-bindings.ts +3 -0
  53. package/src/keyboard/keymap.ts +51 -0
  54. package/src/keyboard/skills.ts +2 -0
  55. package/src/keyboard/slash-palette.ts +8 -0
  56. package/src/keyboard/thread-chords.ts +5 -0
  57. package/src/keyboard/use-shared-keyboard.ts +1 -0
  58. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  59. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  60. package/src/markdown/components/ThreadView.tsx +13 -0
  61. package/src/markdown/mark-runs.ts +4 -0
  62. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  63. package/src/markdown/markdown-grid.ts +6 -0
  64. package/src/markdown/markdown-highlight.ts +1 -0
  65. package/src/markdown/thread-code-syntax.ts +3 -0
  66. package/src/markdown/view-plan.ts +52 -0
  67. package/src/performance/perf-report.ts +1 -0
  68. package/src/performance/perf-timings.ts +1 -0
  69. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  70. package/src/prototype/prototype-browser.ts +11 -0
  71. package/src/settings/components/SettingsDialog.tsx +3 -0
  72. package/src/settings/config.test.ts +79 -1
  73. package/src/settings/config.ts +91 -1
  74. package/src/settings/use-settings-dialog.test.tsx +172 -0
  75. package/src/settings/use-settings-dialog.tsx +115 -1
  76. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  77. package/src/settings/use-thread-harness-setting.ts +86 -0
  78. package/src/stories/stories.test.tsx +4 -0
  79. package/src/stories/story.ts +6 -0
  80. package/src/terminal/ansi-screen.ts +5 -0
  81. package/src/terminal/components/terminal-pane.ts +12 -0
  82. package/src/terminal/ghostty-terminal.ts +10 -0
  83. package/src/terminal/kitty-image.ts +3 -0
  84. package/src/terminal/pty.ts +9 -0
  85. package/src/testing/test-support.test.ts +2 -0
  86. package/src/testing/test-support.ts +16 -0
  87. package/src/thread/agent-continuation.test.ts +2 -0
  88. package/src/thread/agent-transcript.ts +7 -0
  89. package/src/thread/components/AgentConfigControls.test.tsx +41 -0
  90. package/src/thread/components/AgentConfigControls.tsx +8 -1
  91. package/src/thread/components/AgentThreadPane.test.tsx +91 -3
  92. package/src/thread/components/AgentThreadPane.tsx +61 -0
  93. package/src/thread/components/ThreadTree.tsx +1 -0
  94. package/src/thread/components/thread-tree.test.ts +1 -0
  95. package/src/thread/thread-controller.curate.test.ts +2 -0
  96. package/src/thread/thread-controller.share.test.ts +47 -2
  97. package/src/thread/thread-controller.ts +129 -4
  98. package/src/thread/tree-view.ts +4 -0
  99. package/src/thread/use-thread-agent.test.tsx +1 -0
  100. package/src/thread/use-thread-agent.ts +5 -0
  101. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  102. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  103. package/src/ui/components/PromptDialog.tsx +2 -0
  104. package/src/ui/components/RootOverlay.tsx +1 -0
  105. package/src/ui/components/ScrollArea.tsx +2 -0
  106. package/src/ui/components/primitives/Button.tsx +1 -0
  107. package/src/ui/components/primitives/Tabs.tsx +1 -0
  108. package/src/ui/components/primitives/Tree.tsx +4 -0
  109. package/src/ui/components/primitives/tree-model.ts +7 -0
  110. package/src/ui/components/syntax-highlight.ts +5 -0
  111. package/src/ui/components/truncate-title.ts +1 -0
  112. package/src/ui/use-frame-measure.ts +3 -0
  113. package/src/ui/use-terminal-virtualizer.ts +6 -0
  114. package/src/workbench/components/ChangesColumn.tsx +7 -0
  115. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  116. package/src/workbench/components/EditorGrid.tsx +8 -0
  117. package/src/workbench/components/FileContentsView.tsx +2 -0
  118. package/src/workbench/components/GridTabContent.tsx +2 -0
  119. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  120. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  121. package/src/workbench/components/TreePane.tsx +2 -0
  122. package/src/workbench/components/editor-grid.ts +3 -0
  123. package/src/workbench/components/file-tree.ts +2 -0
  124. package/src/workbench/use-changes-workbench.ts +5 -0
  125. package/src/workbench/use-remember-layout.ts +2 -0
  126. 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
+ }
@@ -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(readOnly, role);
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={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
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={(id, title) =>
1293
- setMode({ type: "confirmDelete", sessionId: id, title })
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(enabled: boolean, action: () => void): (() => void) | undefined {
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();
@@ -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: (id: string) => void;
179
- onRename: (id: string, title: string) => void;
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
- return { observer, isOwner: !observer && role === "owner" };
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
  });