@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70

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 (248) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1039 -799
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +461 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +286 -0
  41. package/src/components/AppShell.zoom.test.tsx +94 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +151 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
package/src/App.tsx CHANGED
@@ -1,865 +1,1105 @@
1
- /**
2
- * The cueloop TUI (#22): Ledger IA reduced to its terminal shape - header,
3
- * center artifact pane (projection renderer), right review rail, footer hint
4
- * bar. Thin renderer over daemon state: every mutation round-trips through
5
- * DaemonClient; local state is view state only (cursor, span, overlays).
6
- */
7
-
8
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
- import { useKeyboard } from "@opentui/react";
10
- import { DaemonClient } from "@cueloop/daemon/client";
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore,
8
+ } from "react";
9
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
10
+ import type { Clock } from "@opentui/core";
11
+ import { marksByDisplay, type Mark } from "./view-plan";
12
+ import { dimmedTheme } from "./theme";
11
13
  import {
12
- cutBlock,
13
- makeAnchor,
14
- restoreBlock,
15
- restoreLine,
16
- type Annotation,
17
- type ReviewSession,
18
- type VerdictKind,
19
- } from "@cueloop/schema";
14
+ DEFAULT_KEYS,
15
+ DEFAULT_QUICK_ACTIONS,
16
+ loadConfig,
17
+ persistAuthorName,
18
+ persistDiffView,
19
+ persistPins,
20
+ type AutoClose,
21
+ type DiffViewMode,
22
+ type QuickAction,
23
+ } from "./config";
24
+ import { loadSkills, SlashSkillsContext } from "./skills";
25
+ import type { SlashItem } from "./slash-palette";
20
26
  import {
21
- blockRuns,
22
- buildDisplay,
23
- displayText,
24
- marksByDisplay,
25
- nextWorkBlock,
26
- overlayMarks,
27
- spanKey,
28
- startSpan,
29
- wrapRuns,
30
- type DisplayBlock,
31
- type SpanState,
32
- type StyleRun,
33
- } from "./view";
34
- import { diffRows, diffRowAnchor, diffRowLocation, type DiffRow } from "./view-diff";
35
- import { editInEditor } from "./editor";
36
- import { DARK as T } from "./theme";
37
- import { highlightCode, type CodeToken } from "./syntax";
38
- import { DEFAULT_KEYS, actionFor, loadConfig, persistAutoClose, type AutoClose } from "./config";
39
- import { focusHerdrPane, returnPaneFor } from "./herdr";
40
- import { Registry, type Exporter } from "@cueloop/extension-api";
41
- import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
27
+ composeTheme,
28
+ DEFAULT_THEME_NAME,
29
+ themeForName,
30
+ type Appearance,
31
+ type ThemeName,
32
+ } from "./theme-presets";
33
+ import type { Theme } from "./theme";
34
+ import { createReviewController, type ShareTransport } from "./thread-controller";
35
+ import type { SessionClient } from "@cueloop/daemon/client";
36
+ import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
37
+ import { reduceKey, type KeyState } from "./keymap";
38
+ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
39
+ import { useReadySignal } from "./ready-signal";
40
+ import { ThemeProvider } from "./components/theme-context";
41
+ import { Button } from "./components/primitives/Button";
42
+ import { Toolbar } from "./components/primitives/Toolbar";
43
+ import { groupInbox, projectName, threadTitle } from "./components/session-tree";
44
+ import { ThreadTree } from "./components/ThreadTree";
45
+ import { ChangesFileTree } from "./components/ChangesColumn";
46
+ import { ProjectTreeView } from "./components/ProjectTreeView";
47
+ import { AnnotatableFileView } from "./components/AnnotatableFileView";
48
+ import { AppShell, type ProjectPanelMode } from "./components/AppShell";
49
+ import { EditorGrid } from "./components/EditorGrid";
50
+ import type { EditorTab } from "./components/editor-grid";
51
+ import { useChangesWorkbench } from "./use-changes-workbench";
52
+ import { ThreadFooter } from "./components/ThreadFooter";
53
+ import { ConfirmCard } from "./components/ConfirmCard";
54
+ import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
55
+ import {
56
+ DIFF_CHORD_ENTRIES,
57
+ RAIL_CHORD_ENTRIES,
58
+ resolveThreadChord,
59
+ THREAD_CHORD_ENTRIES,
60
+ TREE_CHORD_ENTRIES,
61
+ } from "./thread-chords";
62
+ import {
63
+ DiffContentView,
64
+ type DiffFoldControls,
65
+ type DiffContentViewProps,
66
+ } from "./components/DiffContentView";
67
+ import { commentCountsByFile, fileTargetMarks, marksByRows, type DiffRow } from "./view-diff";
68
+ import { annotationTarget, type Anchor } from "@cueloop/schema";
69
+ import type { DiffFileContents, Thread } from "@cueloop/schema";
70
+ import { PrototypePixels } from "./prototype-pixels";
71
+ import type { PrototypeElement } from "./prototype-browser";
72
+ import {
73
+ buildRenderFlags,
74
+ buildSubmitConfirmState,
75
+ computeRoleCapabilities,
76
+ deriveReviewFlags,
77
+ isPixelPrototypeMode,
78
+ isCompletionOverlayPhase,
79
+ isWalking,
80
+ resolveOverlay,
81
+ } from "./app-view-model";
82
+ import { buildKeyState } from "./app-key-state";
83
+ import { useSettingsDialog } from "./use-settings-dialog";
84
+ import {
85
+ CompletionScreen,
86
+ ConnectingScreen,
87
+ ErrorScreen,
88
+ MenuChrome,
89
+ NoThreadShell,
90
+ TrailingOverlays,
91
+ } from "./app-screens";
92
+ /** A toast clears itself after this idle; esc dismisses it sooner. */
93
+ const TOAST_DISMISS_MS = 4000;
42
94
 
43
95
  export interface AppProps {
44
96
  home?: string;
45
97
  sessionId?: string;
98
+ /** The launch directory whose git repo backs the no-session welcome tree; defaults to process.cwd(). */
99
+ cwd?: string;
46
100
  /**
47
- * Observer mode (SSH-served connections): every mutating verb is ignored and
101
+ * Observer mode (SSH-served connections): every mutating primitive is ignored and
48
102
  * answers "observer - read-only" in the status line; navigation still works.
49
103
  */
50
104
  readOnly?: boolean;
51
105
  onExit?: (code: number) => void;
52
- }
53
-
54
- type Mode =
55
- | { m: "normal" }
56
- | { m: "span"; span: SpanState }
57
- | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
58
- | { m: "submit"; verdict: VerdictKind; summary: string };
59
-
60
- /** Verbs that write session state; an observer never reaches their handlers. */
61
- const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
62
-
63
- const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
64
- const VERDICT_LABEL: Record<VerdictKind, string> = {
65
- comment: "Comment",
66
- approve: "Approve",
67
- request_changes: "Request changes",
68
- };
69
-
70
- export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
71
- const clientRef = useRef<DaemonClient | null>(null);
72
- const [error, setError] = useState<string | null>(null);
73
- const [inbox, setInbox] = useState<ReviewSession[] | null>(null);
74
- const [session, setSession] = useState<ReviewSession | null>(null);
75
- const [cursor, setCursor] = useState(0);
76
- const [inboxCursor, setInboxCursor] = useState(0);
77
- const [mode, setMode] = useState<Mode>({ m: "normal" });
78
- const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
79
- const [status, setStatus] = useState("");
106
+ /** Fired once, after the first frame that paints a usable screen with its keyboard handlers live (ready-signal.ts). */
107
+ onReady?: () => void;
108
+ /** Timer source for the auto-close countdown; tests inject a ManualClock. */
109
+ clock?: Clock;
110
+ /** Session source; the sharing gateway injects a blob-backed client. */
111
+ openClient?: () => Promise<SessionClient>;
112
+ shareTransport?: ShareTransport;
80
113
  /**
81
- * Post-submit lifecycle (a review pane should hand you back to the agent,
82
- * not linger): idle → prompt (auto-close off: offer the choice) or
83
- * counting (configured delay) → exit. esc dismisses to the resolved view.
114
+ * Who is at the keyboard. `owner` is the local planner (default). `observer`
115
+ * is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
116
+ * viewer: annotates, but cannot edit the plan or submit an agent verdict.
84
117
  */
85
- const [completion, setCompletion] = useState<
86
- { phase: "idle" } | { phase: "prompt" } | { phase: "counting"; remaining: number } | { phase: "dismissed" }
87
- >({ phase: "idle" });
88
- const autoCloseRef = useRef<AutoClose>("off");
89
- /** Close the review and, inside herdr, bounce focus back to the agent. */
90
- const finishReview = useCallback(() => {
91
- const pane = returnPaneFor(session?.artifact.meta.herdrPane);
92
- if (pane) focusHerdrPane(pane);
93
- onExit?.(0);
94
- }, [session, onExit]);
95
- // live mirror of overlay input text: refs commit synchronously, so the
96
- // RETURN handler never reads a stale value mid-typing
97
- const liveInput = useRef("");
98
- // keymap from layered config; theme overrides land on the shared tokens
99
- const keysRef = useRef(DEFAULT_KEYS);
100
- const obsidianRef = useRef<ObsidianConfig | null>(null);
101
- const exportersRef = useRef<Map<string, Exporter>>(new Map());
102
- useEffect(() => {
103
- const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
104
- keysRef.current = cfg.keys;
105
- autoCloseRef.current = cfg.ui.autoClose;
106
- Object.assign(T, cfg.theme);
107
- obsidianRef.current = cfg.integrations.obsidian;
108
- // bundled integrations register through the public extension API
109
- const registry = new Registry();
110
- void registry.load("obsidian", createObsidianExtension(cfg.integrations.obsidian)).then((rec) => {
111
- exportersRef.current = rec.exporters;
112
- });
113
- }, [session?.workspace.repoRoot]);
118
+ role?: "owner" | "observer" | "collaborator";
119
+ /**
120
+ * A collaborator's own SSH fingerprint. On first open of a share it seeds the
121
+ * name prompt so their notes attribute to a name, not a fingerprint.
122
+ */
123
+ selfAuthor?: string;
124
+ /**
125
+ * The terminal's background appearance (from an OSC query at startup). The
126
+ * branded transparent theme darkens its text on a light terminal so it is not
127
+ * light-on-light. Defaults to dark - the historical assumption.
128
+ */
129
+ appearance?: Appearance;
130
+ }
114
131
 
115
- // ── daemon wiring ───────────────────────────
116
- useEffect(() => {
117
- let closed = false;
118
- void (async () => {
119
- try {
120
- const client = await DaemonClient.connect({ home, autostart: true });
121
- if (closed) return void client.close();
122
- clientRef.current = client;
123
- client.onEvent((e) => {
124
- // another controller/observer changed state: re-fetch
125
- if (session && e.sessionId === session.id) void refreshSession(e.sessionId);
126
- if (!sessionId) void refreshInbox();
127
- });
128
- await client.subscribe();
129
- if (sessionId) {
130
- setSession(await client.sessionGet(sessionId));
131
- } else {
132
- setInbox(await client.sessionList({ status: "pending" }));
133
- }
134
- } catch (err) {
135
- setError(err instanceof Error ? err.message : String(err));
136
- }
137
- })();
138
- return () => {
139
- closed = true;
140
- clientRef.current?.close();
141
- };
142
- // eslint-disable-next-line react-hooks/exhaustive-deps
143
- }, [home, sessionId]);
132
+ /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
133
+ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
134
+ return menuDialog !== null;
135
+ }
144
136
 
145
- const refreshSession = useCallback(async (id: string) => {
146
- const c = clientRef.current;
147
- if (c) setSession(await c.sessionGet(id));
148
- }, []);
149
- const refreshInbox = useCallback(async () => {
150
- const c = clientRef.current;
151
- if (c) setInbox(await c.sessionList({ status: "pending" }));
152
- }, []);
137
+ /** True while a menu or an overlay owns the keyboard instead of the thread view. */
138
+ function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["overlay"]): boolean {
139
+ return menuOwnsKeyboard || overlay !== "none";
140
+ }
153
141
 
154
- // ── derived view model ──────────────────────
155
- const working = session ? (session.workingCopy ?? session.artifact.content) : "";
156
- const display = useMemo(
157
- () => (session ? buildDisplay(session.artifact.content, session.workingCopy) : []),
158
- [session],
159
- );
160
- const marks = useMemo(
161
- () => (session ? marksByDisplay(session.annotations, display, focusedAnn) : new Map()),
162
- [session, display, focusedAnn],
163
- );
164
- const resolved = session?.status === "resolved";
165
- const isDiff = session?.artifact.type === "diff";
166
- const rows = useMemo(() => (session && isDiff ? diffRows(session.artifact.content) : []), [session, isDiff]);
167
-
168
- const apply = useCallback((p: Promise<ReviewSession>) => {
169
- p.then(setSession).catch((e: unknown) => setStatus(String(e instanceof Error ? e.message : e)));
170
- }, []);
171
-
172
- // ── verbs ───────────────────────────────────
173
- const verbCut = useCallback(() => {
174
- if (!session || resolved) return;
175
- const d = display[cursor];
176
- if (!d) return;
177
- const c = clientRef.current!;
178
- if (d.type === "del") {
179
- const line = restoreLine(nextWorkBlock(display, cursor), working.split("\n").length);
180
- // restoreBlock returns undefined when the block structure round-trips
181
- // to the submitted revision - the working copy is gone
182
- const restored = restoreBlock(session.artifact.content, working, d.base!, line);
183
- apply(c.sessionSetWorkingCopy(session.id, restored));
184
- setStatus("cut restored");
185
- } else if (d.work) {
186
- apply(c.sessionSetWorkingCopy(session.id, cutBlock(working, d.work)));
187
- setStatus("block cut - it serializes into the diff");
188
- }
189
- }, [session, resolved, display, cursor, working, apply]);
142
+ /** The footer submit fires only for the owner of an unresolved review, never an observer. */
143
+ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
144
+ return isOwner && !resolved && !observer;
145
+ }
190
146
 
191
- const verbEdit = useCallback(() => {
192
- if (!session || resolved) return;
193
- try {
194
- const result = editInEditor(working, "plan.md");
195
- if (result.changed) {
196
- apply(clientRef.current!.sessionSetWorkingCopy(session.id, result.content));
197
- setStatus("edits tracked - one diff");
198
- } else setStatus("no changes");
199
- } catch (err) {
200
- setStatus(err instanceof Error ? err.message : String(err));
201
- }
202
- }, [session, resolved, working, apply]);
203
-
204
- const saveCompose = useCallback(
205
- (m: Extract<Mode, { m: "compose" }>, liveText: string) => {
206
- if (!session || !liveText.trim()) return setMode({ m: "normal" });
207
- let anchor;
208
- if (session.artifact.type === "diff") {
209
- anchor = { ...diffRowAnchor(rows, m.dispIdx), blockIndex: m.dispIdx };
210
- } else {
211
- const workBlocks = display.filter((d) => d.work).map((d) => d.work!);
212
- const workIdx = display.slice(0, m.dispIdx + 1).filter((d) => d.work).length - 1;
213
- anchor = makeAnchor(workBlocks, workIdx, m.start, m.end);
214
- }
215
- apply(
216
- clientRef.current!.sessionAnnotate(session.id, {
217
- id: `a_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
218
- kind: m.kind,
219
- anchor,
220
- body: liveText.trim(),
221
- }),
222
- );
223
- setMode({ m: "normal" });
224
- setStatus(m.kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
225
- },
226
- [session, display, rows, apply],
227
- );
147
+ /** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
148
+ type DiffSurfaceProps = Pick<
149
+ DiffContentViewProps,
150
+ | "session"
151
+ | "quickActions"
152
+ | "observer"
153
+ | "commentsEnabled"
154
+ | "resolved"
155
+ | "suspended"
156
+ | "onComposingChange"
157
+ | "onObserverBlocked"
158
+ | "onCursorChange"
159
+ | "focusedAnnotationId"
160
+ | "onFocusAnnotation"
161
+ | "onAnnotate"
162
+ | "onReply"
163
+ | "onUpdateAnnotation"
164
+ | "onExit"
165
+ >;
228
166
 
229
- const submit = useCallback(
230
- (verdict: VerdictKind, summary: string) => {
231
- if (!session) return;
232
- clientRef.current!
233
- .sessionResolve(session.id, verdict, summary)
234
- .then((resolved) => {
235
- setSession(resolved);
236
- setStatus(`review submitted - ${verdict.replace("_", " ")}`);
237
- // notes-vault export: guarded by config, default is manual (no-op)
238
- const obsidian = obsidianRef.current;
239
- const exporter = exportersRef.current.get("obsidian");
240
- if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
241
- void exporter(resolved).then((r) => {
242
- setStatus(r.success && r.path ? `exported to ${r.path}` : `export failed: ${r.error ?? "unknown"}`);
243
- });
244
- }
245
- // hand the reviewer back to the agent: prompt or count down per config;
246
- // inside herdr with a known return pane, closing is the default
247
- const delay = autoCloseRef.current;
248
- const returns = returnPaneFor(resolved.artifact.meta.herdrPane) !== undefined;
249
- if (delay === 0) finishReview();
250
- else if (typeof delay === "number") setCompletion({ phase: "counting", remaining: delay });
251
- else if (returns) setCompletion({ phase: "counting", remaining: 3 });
252
- else setCompletion({ phase: "prompt" });
253
- })
254
- .catch((e: unknown) => setStatus(String(e instanceof Error ? e.message : e)));
255
- setMode({ m: "normal" });
256
- },
257
- [session, finishReview],
167
+ /** The Changes tab body: the whole diff in one scroll container, or a bare hint when nothing changed. */
168
+ function ChangesTabBody(props: {
169
+ rows: DiffRow[];
170
+ surface: DiffSurfaceProps;
171
+ rejectedRows: Set<number>;
172
+ fold?: DiffFoldControls;
173
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
174
+ split?: boolean;
175
+ dimmed: boolean;
176
+ theme: Theme;
177
+ }): React.ReactNode {
178
+ const annotations = props.surface.session.annotations;
179
+ // a diff thread's Changes view is the artifact itself; any other thread's is the working-tree diff,
180
+ // whose notes carry a file target and resolve per file - each surface paints only its own notes
181
+ const forArtifact = props.surface.session.artifact.type === "diff";
182
+ const marks = useMemo(
183
+ () =>
184
+ forArtifact
185
+ ? marksByRows(
186
+ annotations.filter((annotation) => annotationTarget(annotation).kind === "artifact"),
187
+ props.rows,
188
+ props.surface.focusedAnnotationId,
189
+ )
190
+ : fileTargetMarks(annotations, props.rows, props.surface.focusedAnnotationId),
191
+ [annotations, forArtifact, props.rows, props.surface.focusedAnnotationId],
258
192
  );
259
193
 
260
- useEffect(() => {
261
- if (completion.phase !== "counting") return;
262
- if (completion.remaining <= 0) return void finishReview();
263
- const timer = setTimeout(
264
- () => setCompletion((c) => (c.phase === "counting" ? { phase: "counting", remaining: c.remaining - 1 } : c)),
265
- 1000,
194
+ if (props.rows.length === 0) {
195
+ return (
196
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
197
+ <text fg={props.theme.textDim}>No changes</text>
198
+ </box>
266
199
  );
267
- return () => clearTimeout(timer);
268
- }, [completion, finishReview]);
269
-
270
- // ── keyboard grammar ────────────────────────
271
- useKeyboard((key) => {
272
- const name = key.name;
273
- // compose/submit overlays own the keys via focused inputs; only escape here
274
- if (mode.m === "compose" || mode.m === "submit") {
275
- if (name === "escape") setMode({ m: "normal" });
276
- else if (name === "return" || name === "enter") {
277
- if (mode.m === "compose") saveCompose(mode, liveInput.current);
278
- else submit(mode.verdict, liveInput.current);
279
- } else if (mode.m === "submit" && (name === "left" || name === "right")) {
280
- const dir = name === "left" ? -1 : 1;
281
- const idx = (VERDICTS.indexOf(mode.verdict) + dir + VERDICTS.length) % VERDICTS.length;
282
- setMode({ ...mode, verdict: VERDICTS[idx]! });
283
- }
284
- return;
285
- }
286
- if (completion.phase === "prompt" || completion.phase === "counting") {
287
- if (name === "return" || name === "enter" || name === "q") return finishReview();
288
- if (name === "a" && completion.phase === "prompt") {
289
- // opt in: close in 3s from now on, persisted to the user config
290
- try {
291
- persistAutoClose(3);
292
- } catch {
293
- // a read-only config dir must not block closing the review
294
- }
295
- autoCloseRef.current = 3;
296
- return setCompletion({ phase: "counting", remaining: 3 });
297
- }
298
- if (name === "escape") return setCompletion({ phase: "dismissed" });
299
- return;
300
- }
301
- const action = actionFor(keysRef.current, name, !!key.shift);
302
- if (action === "quit") return onExit?.(0);
303
- if (readOnly && action && MUTATING_ACTIONS.has(action)) return setStatus("observer - read-only");
304
-
305
- // inbox mode
306
- if (!session) {
307
- if (!inbox?.length) return;
308
- if (name === "j" || name === "down") setInboxCursor((c) => Math.min(inbox.length - 1, c + 1));
309
- else if (name === "k" || name === "up") setInboxCursor((c) => Math.max(0, c - 1));
310
- else if (name === "return" || name === "enter") {
311
- const s = inbox[inboxCursor];
312
- if (s) setSession(s);
313
- }
314
- return;
315
- }
316
-
317
- if (session.artifact.type === "diff") {
318
- if (action === "down") setCursor((c) => Math.min(rows.length - 1, c + 1));
319
- else if (action === "up") setCursor((c) => Math.max(0, c - 1));
320
- else if (action === "top") setCursor(0);
321
- else if (action === "bottom") setCursor(rows.length - 1);
322
- else if (action === "comment") {
323
- if (resolved) return setStatus("review submitted - read-only");
324
- const row = rows[cursor];
325
- if (!row || row.t === "file" || row.t === "hunk") return setStatus("move to a code line to comment");
326
- liveInput.current = "";
327
- setMode({ m: "compose", kind: "comment", dispIdx: cursor, start: 0, end: row.text.length, text: "" });
328
- } else if (action === "next_annotation" || action === "prev_annotation") {
329
- const anns = session.annotations;
330
- if (!anns.length) return setStatus("no annotations");
331
- const idx = anns.findIndex((a) => a.id === focusedAnn);
332
- const next = idx === -1 ? 0 : (idx + (action === "next_annotation" ? 1 : -1) + anns.length) % anns.length;
333
- setFocusedAnn(anns[next]!.id);
334
- } else if (action === "delete_annotation") {
335
- if (resolved || !focusedAnn) return;
336
- apply(clientRef.current!.sessionRemoveAnnotation(session.id, focusedAnn));
337
- setFocusedAnn(undefined);
338
- } else if (action === "submit") {
339
- if (!resolved) {
340
- liveInput.current = "";
341
- setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
342
- }
343
- } else if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
344
- setStatus("plan-only verb - diff review uses c on a line");
345
- }
346
- return;
347
- }
348
-
349
- const vis = display;
350
- if (mode.m === "span") {
351
- if (name === "escape") return setMode({ m: "normal" });
352
- if (["l", "h", "w", "b", "$", "0"].includes(name)) {
353
- return setMode({ m: "span", span: spanKey(mode.span, name, displayText(vis[mode.span.dispIdx]!)) });
354
- }
355
- if (name === "c" || name === "s") {
356
- if (readOnly) return setStatus("observer - read-only");
357
- liveInput.current = "";
358
- return setMode({
359
- m: "compose",
360
- kind: name === "s" ? "suggestion" : "comment",
361
- dispIdx: mode.span.dispIdx,
362
- start: mode.span.start,
363
- end: mode.span.end,
364
- text: "",
365
- });
366
- }
367
- return;
368
- }
369
-
370
- // normal mode
371
- if (action === "down") setCursor((c) => Math.min(vis.length - 1, c + 1));
372
- else if (action === "up") setCursor((c) => Math.max(0, c - 1));
373
- else if (action === "top") setCursor(0);
374
- else if (action === "bottom") setCursor(vis.length - 1);
375
- else if (action === "span") {
376
- const d = vis[cursor];
377
- if (d?.work) {
378
- const span = startSpan(cursor, displayText(d));
379
- if (span) setMode({ m: "span", span });
380
- }
381
- } else if (action === "comment" || action === "suggest") {
382
- if (resolved) return setStatus("review submitted - read-only");
383
- const d = vis[cursor];
384
- if (!d?.work) return setStatus("text is cut - restore it first");
385
- liveInput.current = "";
386
- setMode({
387
- m: "compose",
388
- kind: action === "suggest" ? "suggestion" : "comment",
389
- dispIdx: cursor,
390
- start: 0,
391
- end: displayText(d).length,
392
- text: "",
393
- });
394
- } else if (action === "cut") {
395
- if (resolved) return setStatus("review submitted - read-only");
396
- verbCut();
397
- } else if (action === "edit") {
398
- if (resolved) return setStatus("review submitted - read-only");
399
- verbEdit();
400
- } else if (action === "next_annotation" || action === "prev_annotation") {
401
- const anns = session.annotations;
402
- if (!anns.length) return setStatus("no annotations");
403
- const idx = anns.findIndex((a) => a.id === focusedAnn);
404
- const next = idx === -1 ? 0 : (idx + (action === "next_annotation" ? 1 : -1) + anns.length) % anns.length;
405
- setFocusedAnn(anns[next]!.id);
406
- } else if (action === "delete_annotation") {
407
- if (resolved || !focusedAnn) return;
408
- apply(clientRef.current!.sessionRemoveAnnotation(session.id, focusedAnn));
409
- setFocusedAnn(undefined);
410
- setStatus("annotation deleted");
411
- } else if (action === "submit") {
412
- if (!resolved) {
413
- liveInput.current = "";
414
- setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
415
- }
416
- }
417
- });
200
+ }
418
201
 
419
- // ── render ──────────────────────────────────
420
- if (error) return <text fg={T.red}>cueloop: {error}</text>;
421
- if (!session && !inbox) return <text fg={T.textDim}>connecting to the daemon…</text>;
422
- if (!session && inbox) return <Inbox inbox={inbox} cursor={inboxCursor} />;
202
+ return (
203
+ <DiffContentView
204
+ rows={props.rows}
205
+ marks={marks}
206
+ {...props.surface}
207
+ rejectedRows={props.rejectedRows}
208
+ fold={props.fold}
209
+ fileStats={props.fileStats}
210
+ split={props.split}
211
+ theme={props.dimmed ? dimmedTheme(props.theme) : undefined}
212
+ />
213
+ );
214
+ }
423
215
 
424
- const s = session!;
425
- const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
216
+ /** An editor tab's body: the whole diff for the Changes tab, a file's diff or contents for a file tab. */
217
+ function GridTabContent(props: {
218
+ tab: EditorTab;
219
+ rows: DiffRow[];
220
+ surface: DiffSurfaceProps;
221
+ rejectedRows: Set<number>;
222
+ fold?: DiffFoldControls;
223
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
224
+ split?: boolean;
225
+ dimmed: boolean;
226
+ readFile: (path: string) => Promise<string | null>;
227
+ /** Persist a comment on a project file; the file view built the anchor against its own lines. */
228
+ onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
229
+ theme: Theme;
230
+ }): React.ReactNode {
231
+ const { tab } = props;
232
+ if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
233
+ const filePath = tab.path;
426
234
 
427
- if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
428
235
  return (
429
- <CompletionOverlay
430
- verdict={s.verdict.kind}
431
- completion={completion}
432
- status={status}
433
- returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
236
+ <AnnotatableFileView
237
+ path={filePath}
238
+ loadContents={props.readFile}
239
+ session={props.surface.session}
240
+ quickActions={props.surface.quickActions}
241
+ observer={props.surface.observer}
242
+ resolved={props.surface.resolved}
243
+ suspended={props.surface.suspended}
244
+ focusedAnnotationId={props.surface.focusedAnnotationId}
245
+ onFocusAnnotation={props.surface.onFocusAnnotation}
246
+ onComposingChange={props.surface.onComposingChange}
247
+ onCursorChange={props.surface.onCursorChange}
248
+ onObserverBlocked={props.surface.onObserverBlocked}
249
+ onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
250
+ onReply={props.surface.onReply}
251
+ onUpdateAnnotation={props.surface.onUpdateAnnotation}
252
+ onExit={props.surface.onExit}
253
+ theme={props.theme}
434
254
  />
435
255
  );
436
256
  }
437
-
438
- return (
439
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
440
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
441
- <text fg={T.text}>
442
- <span fg={T.accent}>cueloop</span>
443
- <span fg={T.textDim}> · {s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev {s.revisions.length}</span>
444
- {resolved ? <span fg={T.green}> · resolved: {s.verdict!.kind.replace("_", " ")}</span> : null}
445
- {readOnly ? <span fg={T.textDim}> · observer</span> : null}
446
- {status ? <span fg={T.accent}> · {status}</span> : null}
447
- </text>
448
- </box>
449
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
450
- {isDiff ? (
451
- <DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
452
- ) : (
453
- <PlanPane display={display} marks={marks} cursor={cursor} mode={mode} />
454
- )}
455
- <Rail session={s} focusedAnn={focusedAnn} pendingCount={pendingCount} />
456
- </box>
457
- {mode.m === "compose" ? (
458
- <ComposeBar mode={mode} quote={isDiff ? (rows[mode.dispIdx]?.text ?? "") : displayText(display[mode.dispIdx]!).slice(mode.start, mode.end)} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
459
- ) : mode.m === "submit" ? (
460
- <SubmitBar mode={mode} pendingCount={pendingCount} onChange={(summary) => { liveInput.current = summary; setMode({ ...mode, summary }); }} />
461
- ) : (
462
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1 }}>
463
- <text fg={T.textDim}>
464
- {readOnly
465
- ? "observer - read-only · j/k move · n/p annotations · q quit"
466
- : mode.m === "span"
467
- ? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
468
- : "j/k move · v span · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
469
- </text>
470
- </box>
471
- )}
472
- </box>
257
+ if (tab.kind !== "file") {
258
+ return (
259
+ <ChangesTabBody
260
+ rows={props.rows}
261
+ surface={props.surface}
262
+ rejectedRows={props.rejectedRows}
263
+ fold={props.fold}
264
+ fileStats={props.fileStats}
265
+ split={props.split}
266
+ dimmed={props.dimmed}
267
+ theme={props.theme}
268
+ />
269
+ );
270
+ }
271
+ // a single-file tab shows that file's rows alone, so its row indices are its own: comments and
272
+ // the caret report back in whole-diff indices, and the fold controls (a band to fold) do not apply
273
+ const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
274
+ const rows = fileRowIndices.map((index) => props.rows[index]!);
275
+ const wholeIndex = (rowIndex: number): number => fileRowIndices[rowIndex] ?? rowIndex;
276
+ const rejectedRows = new Set(
277
+ fileRowIndices.flatMap((index, rowIndex) => (props.rejectedRows.has(index) ? [rowIndex] : [])),
473
278
  );
474
- }
475
279
 
476
- // ── panes ─────────────────────────────────────
477
-
478
- function PlanPane({
479
- display,
480
- marks,
481
- cursor,
482
- mode,
483
- }: {
484
- display: DisplayBlock[];
485
- marks: Map<number, { start: number; end: number; role: StyleRun["role"]; annotationId?: string }[]>;
486
- cursor: number;
487
- mode: Mode;
488
- }): React.ReactNode {
489
- const width = 76;
490
280
  return (
491
- <scrollbox style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
492
- {display.map((d, i) => {
493
- const isCursor = i === cursor;
494
- const gap = topGap(display[i - 1], d);
495
- if (d.kind === "code") {
496
- return (
497
- <CodeBlock
498
- key={i}
499
- block={d}
500
- isCursor={isCursor}
501
- gap={gap}
502
- annotated={(marks.get(i) ?? []).length > 0}
503
- />
504
- );
505
- }
506
- const blockMarks = [...(marks.get(i) ?? [])];
507
- if (mode.m === "span" && mode.span.dispIdx === i) {
508
- blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
509
- }
510
- const runs = overlayMarks(blockRuns(d, true), blockMarks);
511
- const lines = wrapRuns(runs, width - marker(d).length);
512
- return (
513
- <box key={i} style={{ flexDirection: "column", marginTop: gap }}>
514
- {lines.map((line, li) => (
515
- <text key={li} bg={isCursor ? T.cursorBg : undefined}>
516
- <span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
517
- <span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
518
- {line.map((r, ri) => (
519
- <span key={ri} {...runStyle(r, d)}>
520
- {r.text}
521
- </span>
522
- ))}
523
- {li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
524
- </text>
525
- ))}
526
- </box>
527
- );
528
- })}
529
- </scrollbox>
281
+ <ChangesTabBody
282
+ rows={rows}
283
+ surface={{
284
+ ...props.surface,
285
+ onCursorChange: (rowIndex) => props.surface.onCursorChange?.(wholeIndex(rowIndex)),
286
+ onAnnotate: (span, body) =>
287
+ props.surface.onAnnotate(
288
+ {
289
+ start: { blockIndex: wholeIndex(span.start.blockIndex), char: span.start.char },
290
+ end: { blockIndex: wholeIndex(span.end.blockIndex), char: span.end.char },
291
+ },
292
+ body,
293
+ ),
294
+ }}
295
+ rejectedRows={rejectedRows}
296
+ fileStats={props.fileStats}
297
+ split={props.split}
298
+ dimmed={props.dimmed}
299
+ theme={props.theme}
300
+ />
530
301
  );
531
302
  }
532
303
 
533
- function CodeBlock({
534
- block,
535
- isCursor,
536
- gap,
537
- annotated,
538
- }: {
539
- block: DisplayBlock;
540
- isCursor: boolean;
541
- gap: number;
542
- annotated: boolean;
304
+ /** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
305
+ function ProjectPanelBody(props: {
306
+ mode: ProjectPanelMode;
307
+ loadChanges: () => Promise<readonly DiffFileContents[]>;
308
+ loadProjectFiles: () => Promise<string[]>;
309
+ reloadKey: string;
310
+ onOpenChangedFile: (path: string) => void;
311
+ onOpenProjectFile: (path: string) => void;
312
+ commentCounts?: ReadonlyMap<string, number>;
313
+ theme: Theme;
543
314
  }): React.ReactNode {
544
- const lang = (block.work ?? block.base)?.lang;
545
- const content = displayText(block);
546
- // Shiki tokens arrive async; plain verbatim lines render until then
547
- const [tokens, setTokens] = useState<CodeToken[][] | null>(null);
315
+ const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
316
+ const loadRef = useRef(props.loadChanges);
548
317
  useEffect(() => {
549
- let live = true;
550
- void highlightCode(content, lang).then((t) => {
551
- if (live && t) setTokens(t);
552
- });
318
+ loadRef.current = props.loadChanges;
319
+ });
320
+ useEffect(() => {
321
+ let alive = true;
322
+
323
+ void loadRef.current().then(
324
+ (files) => {
325
+ if (alive) setChanges(files);
326
+ },
327
+ () => {
328
+ if (alive) setChanges([]);
329
+ },
330
+ );
331
+
553
332
  return () => {
554
- live = false;
333
+ alive = false;
555
334
  };
556
- }, [content, lang]);
557
- const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
335
+ }, [props.reloadKey, props.mode]);
336
+
337
+ if (props.mode === "changes") {
338
+ return (
339
+ <ChangesFileTree
340
+ files={changes}
341
+ onSelectFile={props.onOpenChangedFile}
342
+ commentCounts={props.commentCounts}
343
+ theme={props.theme}
344
+ />
345
+ );
346
+ }
558
347
  return (
559
- <box style={{ flexDirection: "column", marginTop: gap }}>
560
- <text>
561
- <span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
562
- <span fg={T.textDim}>{lang ?? "code"}</span>
563
- {annotated ? <span fg={T.accent}> ◆</span> : null}
564
- {block.type !== "same" ? <span fg={tagColor(block)}> [{tagLabel(block)}]</span> : null}
565
- </text>
566
- <box
567
- style={{
568
- flexDirection: "column",
569
- backgroundColor: T.elevated,
570
- paddingLeft: 2,
571
- paddingRight: 2,
572
- paddingTop: 1,
573
- paddingBottom: 1,
574
- marginLeft: 2,
575
- }}
576
- >
577
- {lines.map((line, li) => (
578
- <text key={li}>
579
- {line.length === 0 || (line.length === 1 && line[0]!.content === "") ? (
580
- " "
581
- ) : (
582
- line.map((tok, ti) => (
583
- <span key={ti} fg={tok.color ?? T.textMuted}>
584
- {tok.content}
585
- </span>
586
- ))
587
- )}
588
- </text>
589
- ))}
590
- </box>
591
- </box>
348
+ <ProjectTreeView
349
+ key={props.reloadKey}
350
+ loadFiles={props.loadProjectFiles}
351
+ onSelectFile={props.onOpenProjectFile}
352
+ theme={props.theme}
353
+ />
592
354
  );
593
355
  }
594
356
 
595
- /** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
596
- function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
597
- if (!prev) return 0;
598
- const tightPair =
599
- (cur.kind === "li" && prev.kind === "li") || (cur.kind === "oli" && prev.kind === "oli");
600
- return tightPair ? 0 : 1;
357
+ /** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
358
+ function usableScreenReached(
359
+ error: string | null,
360
+ session: Thread | null,
361
+ inbox: Thread[] | null,
362
+ ): boolean {
363
+ return Boolean(error) || session !== null || inbox !== null;
601
364
  }
602
365
 
603
- function Rail({
604
- session,
605
- focusedAnn,
606
- pendingCount,
607
- }: {
608
- session: ReviewSession;
609
- focusedAnn?: string;
610
- pendingCount: number;
611
- }): React.ReactNode {
612
- return (
613
- <box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
614
- <text fg={T.textDim}>REVIEW ({pendingCount})</text>
615
- <text fg={T.textDim}>{session.workingCopy !== undefined ? "± plan edits → one diff" : " no direct edits"}</text>
616
- <text> </text>
617
- {session.annotations.length === 0 ? (
618
- <text fg={T.textDim}>no annotations yet</text>
619
- ) : (
620
- session.annotations.map((a) => <AnnCard key={a.id} a={a} focused={a.id === focusedAnn} />)
621
- )}
622
- <box style={{ flexGrow: 1 }} />
623
- <text fg={session.status === "resolved" ? T.green : T.accent}>
624
- {session.status === "resolved" ? `resolved: ${session.verdict!.kind.replace("_", " ")}` : `Submit review (${pendingCount}) ⏎`}
625
- </text>
626
- </box>
627
- );
628
- }
366
+ /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
367
+ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
368
+ const base = keyBindings.cheatsheet();
629
369
 
630
- function AnnCard({ a, focused }: { a: Annotation; focused: boolean }): React.ReactNode {
631
- const color = a.kind === "suggestion" ? T.green : T.accent;
632
- return (
633
- <box style={{ flexDirection: "column", marginBottom: 1 }}>
634
- <text fg={focused ? T.text : color}>
635
- {focused ? "▸ " : " "}
636
- {a.kind.toUpperCase()}
637
- </text>
638
- <text fg={T.textDim}> “{truncate(a.anchor.quote, 26)}”</text>
639
- <text fg={T.textMuted}> {truncate(a.body, 28)}</text>
640
- </box>
641
- );
370
+ if (!threadViewActive) {
371
+ return base;
372
+ }
373
+
374
+ return [
375
+ ...THREAD_VIEW_CHEATSHEET,
376
+ { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
377
+ { title: "Diff", entries: [...DIFF_CHORD_ENTRIES] },
378
+ { title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
379
+ { title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
380
+ ...base.filter((section) => section.title === "Agent terminal"),
381
+ ];
642
382
  }
643
383
 
644
- function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
645
- return (
646
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
647
- <text fg={T.accent}>cueloop · inbox ({inbox.length} pending)</text>
648
- <text> </text>
649
- {inbox.length === 0 ? (
650
- <text fg={T.textDim}>nothing waiting for review</text>
651
- ) : (
652
- inbox.map((s, i) => (
653
- <text key={s.id} fg={i === cursor ? T.text : T.textMuted} bg={i === cursor ? T.cursorBg : undefined}>
654
- {i === cursor ? "▸ " : " "}
655
- {s.artifact.meta.title ?? s.id} · {s.workspace.branch} · {s.artifact.type}
656
- </text>
657
- ))
658
- )}
659
- <box style={{ flexGrow: 1 }} />
660
- <text fg={T.textDim}>j/k move · ⏎ open · q quit</text>
661
- </box>
384
+ export function App({
385
+ home,
386
+ sessionId,
387
+ cwd,
388
+ readOnly = false,
389
+ onExit,
390
+ onReady,
391
+ clock,
392
+ openClient,
393
+ shareTransport,
394
+ role = "owner",
395
+ selfAuthor,
396
+ appearance = "dark",
397
+ }: AppProps): React.ReactNode {
398
+ const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
399
+ const controller = useMemo(
400
+ () =>
401
+ createReviewController({
402
+ home,
403
+ sessionId,
404
+ cwd,
405
+ readOnly: observer,
406
+ onExit,
407
+ clock,
408
+ openClient,
409
+ shareTransport,
410
+ }),
411
+ // eslint-disable-next-line react-hooks/exhaustive-deps
412
+ [home, sessionId],
662
413
  );
663
- }
664
414
 
665
- function ComposeBar({
666
- mode,
667
- quote,
668
- onChange,
669
- }: {
670
- mode: Extract<Mode, { m: "compose" }>;
671
- quote: string;
672
- onChange: (text: string) => void;
673
- }): React.ReactNode {
674
- return (
675
- <box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
676
- <text fg={mode.kind === "suggestion" ? T.green : T.accent}>
677
- {mode.kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
678
- </text>
679
- <input focused value={mode.text} onInput={onChange} />
680
- </box>
415
+ useEffect(() => {
416
+ controller.connect();
417
+
418
+ return () => controller.close();
419
+ }, [controller]);
420
+ // stable across renders so the memoized PrototypeContentView is not
421
+ // re-rendered by unrelated App state (status ticks, a rail-width drag)
422
+ const onCommentPrototype = useCallback(
423
+ (element: PrototypeElement, body: string) =>
424
+ controller.annotatePrototype(element.selector, element.quote, body),
425
+ [controller],
681
426
  );
682
- }
427
+ const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
428
+ useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
683
429
 
684
- function SubmitBar({
685
- mode,
686
- pendingCount,
687
- onChange,
688
- }: {
689
- mode: Extract<Mode, { m: "submit" }>;
690
- pendingCount: number;
691
- onChange: (summary: string) => void;
692
- }): React.ReactNode {
693
- return (
694
- <box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
695
- <text>
696
- <span fg={T.textDim}>verdict ←/→ : </span>
697
- {VERDICTS.map((v, i) => (
698
- <span key={v} fg={v === mode.verdict ? verdictColor(v) : T.textDim}>
699
- {v === mode.verdict ? `[${VERDICT_LABEL[v]}]` : ` ${VERDICT_LABEL[v]} `}
700
- {i < VERDICTS.length - 1 ? " " : ""}
701
- </span>
702
- ))}
703
- <span fg={T.textDim}> · Submit review ({pendingCount}) on ⏎ · esc cancel</span>
704
- </text>
705
- <input focused value={mode.summary} onInput={onChange} placeholder="summary for the agent (optional)" />
706
- </box>
430
+ // A shared plan you own polls for collaborator notes while it is open; the
431
+ // merge refreshes through the normal event path. Stops on leave.
432
+ useEffect(() => {
433
+ if (!isOwner || !session?.shareId) return;
434
+
435
+ return controller.startShareSync();
436
+ }, [isOwner, session?.id, session?.shareId, controller]);
437
+ const renderer = useRenderer();
438
+ const { width: terminalWidth } = useTerminalDimensions();
439
+
440
+ // ── view state ──────────────────────────────
441
+ const [cursor, setCursor] = useState(0);
442
+ const [inboxCursor, setInboxCursor] = useState(0);
443
+ // Projects and Threads grouping; ordered is the flat sequence the inbox cursor walks
444
+ // pinned threads are client-local view state, seeded from the user config and
445
+ // persisted on toggle; a pin lifts the thread into the sidebar's Pinned section
446
+ const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set());
447
+ const grouped = useMemo(() => groupInbox(inbox ?? [], pinnedIds), [inbox, pinnedIds]);
448
+ const togglePin = (id: string): void =>
449
+ setPinnedIds((current) => {
450
+ const next = new Set(current);
451
+
452
+ if (next.has(id)) next.delete(id);
453
+ else next.add(id);
454
+ persistPins([...next]);
455
+
456
+ return next;
457
+ });
458
+ // the left Projects and Threads column; collapsed by default per the context
459
+ // matrix (the thread owns the width), toggled open to jump between threads
460
+ // one sidebar-open state across the no-thread shell and the thread view, so
461
+ // picking a thread keeps the sidebar as it was. It opens when the app lands with
462
+ // nothing selected (pick a thread) and stays collapsed on a direct thread open,
463
+ // where the thread owns the width.
464
+ const [sidebarOpen, setSidebarOpen] = useState(sessionId === undefined);
465
+ // the Changes + Project right region and its editor grid (tabs, splits, zoom)
466
+ const workbench = useChangesWorkbench();
467
+ const [mode, setMode] = useState<Mode>({ type: "normal" });
468
+ // the top-left settings gear drop-down and the centered dialog it opens
469
+ const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
470
+ const [autoClose, setAutoClose] = useState<AutoClose>("off");
471
+ // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
472
+ const [diffView, setDiffView] = useState<DiffViewMode>("split");
473
+ const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
474
+ const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
475
+ const [railTab, setRailTab] = useState<RailTab>("review");
476
+ // the tree row the reviewer stands on in the session tree
477
+ const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
478
+ // ~2s focus pulse on the document highlight when a card is activated
479
+ const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
480
+ const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
481
+ // live mirror of overlay input text: refs commit synchronously, so the
482
+ // RETURN handler never reads a stale value mid-typing
483
+ const liveInput = useRef("");
484
+ // keymap from layered config; the loaded theme swaps the provider value
485
+ const keysRef = useRef(DEFAULT_KEYS);
486
+ const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
487
+ const [theme, setTheme] = useState(() => themeForName(DEFAULT_THEME_NAME, appearance));
488
+ const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
489
+ const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
490
+ const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
491
+ const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
492
+ const [skills, setSkills] = useState<SlashItem[]>([]);
493
+ // opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
494
+ const [prototypePixels, setPrototypePixels] = useState(false);
495
+
496
+ useEffect(() => {
497
+ const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
498
+
499
+ setPrototypePixels(config.experimental.prototypePixels);
500
+ keysRef.current = config.keys;
501
+ keyBindings.setKeys(config.keys);
502
+ setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
503
+ setThemeName(config.ui.theme);
504
+ setThemeOverrides(config.themeOverrides);
505
+ setAuthorNames(config.authors);
506
+ setQuickActions(config.actions);
507
+ setSkills(loadSkills(config.skillsPath));
508
+ setAutoClose(config.ui.autoClose);
509
+ setDiffView(config.ui.diffView);
510
+ setPinnedIds(new Set(config.ui.pins));
511
+ controller.applyConfig(config);
512
+ }, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
513
+ useEffect(
514
+ () => () => {
515
+ if (pulseTimer.current) clearTimeout(pulseTimer.current);
516
+ },
517
+ [],
707
518
  );
708
- }
709
519
 
710
- function DiffPane({
711
- rows,
712
- cursor,
713
- annotations,
714
- focusedAnn,
715
- }: {
716
- rows: DiffRow[];
717
- cursor: number;
718
- annotations: Annotation[];
719
- focusedAnn?: string;
720
- }): React.ReactNode {
721
- // simple windowing: keep the cursor in view without a scroll dependency
722
- const windowStart = Math.max(0, cursor - 12);
723
- const visible = rows.slice(windowStart, windowStart + 200);
724
- const annotated = new Map<number, Annotation>();
725
- for (const a of annotations) {
726
- const idx = rows.findIndex((r) => r.text === a.anchor.quote && (r.t === "ctx" || r.t === "add" || r.t === "del"));
727
- if (idx !== -1) annotated.set(idx, a);
728
- }
729
- return (
730
- <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
731
- {visible.map((row, vi) => {
732
- const i = windowStart + vi;
733
- const isCursor = i === cursor;
734
- const ann = annotated.get(i);
735
- const gutter = row.t === "del" ? (row.oldLine?.toString() ?? "") : (row.newLine?.toString() ?? "");
736
- const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
737
- if (row.t === "file") {
738
- return (
739
- <text key={i} fg={T.text} bg={isCursor ? T.cursorBg : T.panel}>
740
- {isCursor ? "▎" : " "}■ {row.text}
741
- </text>
742
- );
743
- }
744
- if (row.t === "hunk") {
745
- return (
746
- <text key={i} fg={T.blue} bg={isCursor ? T.cursorBg : undefined}>
747
- {isCursor ? "▎" : " "}{row.text}
748
- </text>
749
- );
750
- }
751
- const fg = row.t === "add" ? T.insFg : row.t === "del" ? T.delFg : T.textMuted;
752
- return (
753
- <box key={i} style={{ flexDirection: "column" }}>
754
- <text bg={isCursor ? T.cursorBg : ann ? T.markCommentBg : undefined}>
755
- <span fg={T.textDim}>{isCursor ? "▎" : " "}{gutter.padStart(4)} </span>
756
- <span fg={fg}>{sign}{row.text}</span>
757
- </text>
758
- {ann ? (
759
- <text>
760
- <span fg={T.textDim}>{" "}</span>
761
- <span fg={ann.id === focusedAnn ? T.text : T.accent}>◆ {truncate(ann.body, 70)}</span>
762
- </text>
763
- ) : null}
764
- </box>
765
- );
766
- })}
767
- </box>
520
+ useEffect(() => {
521
+ if (!toast) return;
522
+ const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
523
+
524
+ return () => clearTimeout(timer);
525
+ }, [toast, controller]);
526
+
527
+ // First open of a share: ask the collaborator for a display name once, unless
528
+ // a past visit already recorded one. esc skips and their notes read anonymous.
529
+ const promptedSelfRef = useRef(false);
530
+
531
+ useEffect(() => {
532
+ if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
533
+ promptedSelfRef.current = true;
534
+ const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
535
+
536
+ if (!known) setMode({ type: "nameSelf", text: "" });
537
+ }, [role, selfAuthor, session]);
538
+
539
+ // ── settings dialog: config-backed model, navigation, persistence ──
540
+ const {
541
+ settingsNav,
542
+ settingsCategories,
543
+ settingsValues,
544
+ cycleSetting,
545
+ handleSettingsKey,
546
+ onCategorySelect,
547
+ } = useSettingsDialog({
548
+ theme,
549
+ appearance,
550
+ autoClose,
551
+ setAutoClose,
552
+ diffView,
553
+ setDiffView,
554
+ themeName,
555
+ setThemeName,
556
+ themeOverrides,
557
+ setTheme,
558
+ quickActions,
559
+ setQuickActions,
560
+ setMenuDialog,
561
+ });
562
+
563
+ // ── derived view model ──────────────────────
564
+ const display = controller.display();
565
+ const rows = controller.rows();
566
+ const rejectedRows = controller.rejectedRows();
567
+ const marks = useMemo(
568
+ () =>
569
+ session
570
+ ? marksByDisplay(
571
+ // the plan thread shows only artifact notes; Changes-diff notes render on that surface
572
+ session.annotations.filter(
573
+ (annotation) => annotationTarget(annotation).kind === "artifact",
574
+ ),
575
+ display,
576
+ pulsedAnnotationId ?? undefined,
577
+ )
578
+ : new Map<number, Mark[]>(),
579
+ [session, display, pulsedAnnotationId],
768
580
  );
769
- }
581
+ /** Annotation ids whose anchor resolved against the working copy. */
582
+ const resolvedIds = useMemo(() => {
583
+ const ids = new Set<string>();
770
584
 
771
- function CompletionOverlay({
772
- verdict,
773
- completion,
774
- status,
775
- returnsTo,
776
- }: {
777
- verdict: VerdictKind;
778
- completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
779
- /** Latest status line (e.g. the vault-export path) stays visible here. */
780
- status: string;
781
- /** Where focus goes on close (the agent's herdr pane), when known. */
782
- returnsTo?: string;
783
- }): React.ReactNode {
784
- const approved = verdict === "approve";
785
- return (
786
- <box
787
- style={{
788
- width: "100%",
789
- height: "100%",
790
- backgroundColor: T.bg,
791
- flexDirection: "column",
792
- justifyContent: "center",
793
- alignItems: "center",
794
- }}
795
- >
796
- <text fg={approved ? T.green : T.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
797
- <text> </text>
798
- <text fg={T.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
799
- {status ? <text fg={T.textDim}>{status}</text> : null}
800
- <text> </text>
801
- {completion.phase === "counting" ? (
802
- <text fg={T.textDim}>
803
- closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
804
- </text>
805
- ) : (
806
- <text fg={T.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
807
- )}
808
- </box>
585
+ for (const blockMarks of marks.values()) {
586
+ for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
587
+ }
588
+
589
+ return ids;
590
+ }, [marks]);
591
+ const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
592
+ // a prototype is a markdown design doc by default; only the opt-in experimental
593
+ // pixel mode (flag on + an HTML entry) renders it as a kitty mockup instead
594
+ const isPixelPrototype = isPixelPrototypeMode(
595
+ isPrototype,
596
+ prototypePixels,
597
+ session?.artifact.meta.prototypePath,
809
598
  );
810
- }
599
+ // comments per changed-file path, for the changed-files tree and tab badges
600
+ const diffCommentCounts = useMemo(
601
+ () => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
602
+ [session, isDiff, rows],
603
+ );
604
+ // entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
605
+ workbench.syncSession(session?.id, isDiff);
606
+ // plans, replies, and the default (markdown) prototype open in the thread view, diffs in the
607
+ // diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
608
+ const threadViewActive = session !== null && !isPixelPrototype;
609
+ const [threadComposing, setThreadComposing] = useState(false);
610
+ const [prototypeComposing, setPrototypeComposing] = useState(false);
611
+ const [welcomeComposing, setWelcomeComposing] = useState(false);
612
+ // sort position per annotation so the rail interleaves annotation and removal
613
+ // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
614
+ // annotation resolves to the display index it marked
615
+ // ── the guided walk's view model ────────────
616
+ const walkFileList = controller.files();
617
+ const walking = isWalking(isDiff, walk);
618
+ const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
811
619
 
812
- // ── small helpers ─────────────────────────────
620
+ // ── selection symmetry: one selected id, both sides ──
621
+ const selectCardFromDocument = (annotationId: string): void => {
622
+ setFocusedAnnotationId(annotationId);
623
+ };
813
624
 
814
- function marker(d: DisplayBlock): string {
815
- if (d.kind === "li") return "- ";
816
- if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
817
- if (d.kind === "quote") return "▏ ";
818
- return "";
819
- }
625
+ const openCardEdit = (annotationId: string): void => {
626
+ if (observer) return controller.setStatus("observer - read-only");
627
+ const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
820
628
 
821
- function cursorGlyph(isCursor: boolean): string {
822
- return isCursor ? "▎ " : " ";
823
- }
629
+ if (!annotation) return;
630
+ // a collaborator's note is theirs to word: activating it (click or e) renames
631
+ // the author rather than editing the body the planner does not own
632
+ if (annotation.author) {
633
+ setFocusedAnnotationId(annotationId);
824
634
 
825
- function tagLabel(d: DisplayBlock): string {
826
- return d.type === "del" ? "cut" : d.type === "add" ? "new" : "edited";
827
- }
635
+ return void setMode({
636
+ type: "rename",
637
+ authorId: annotation.author,
638
+ text: authorNames[annotation.author] ?? "",
639
+ });
640
+ }
641
+ if (resolved) return controller.setStatus("review submitted - read-only");
642
+ liveInput.current = annotation.body;
643
+ setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
644
+ };
828
645
 
829
- function tagColor(d: DisplayBlock): string {
830
- return d.type === "del" ? T.red : d.type === "add" ? T.green : T.accent;
831
- }
646
+ /** The $EDITOR hand-off releases the terminal: suspend, edit, resume. */
647
+ const runEditorHandOff = (): void => {
648
+ renderer?.suspend();
649
+ try {
650
+ controller.edit();
651
+ } finally {
652
+ renderer?.resume();
653
+ }
654
+ };
832
655
 
833
- function verdictColor(v: VerdictKind): string {
834
- return v === "approve" ? T.green : v === "request_changes" ? T.red : T.blue;
835
- }
656
+ // ── keyboard grammar: build state, reduce, dispatch ──
657
+ const dispatch = createIntentDispatch({
658
+ controller,
659
+ onExit,
660
+ isDiff,
661
+ display,
662
+ rows,
663
+ cursor,
664
+ inbox: inbox === null ? null : grouped.ordered,
665
+ inboxCursor,
666
+ mode,
667
+ session,
668
+ focusedAnnotationId,
669
+ selectedCurationId,
670
+ railTab,
671
+ selectedEntryId,
672
+ authorNames,
673
+ quickActions,
674
+ renameAuthor: (id: string, name: string) => {
675
+ persistAuthorName(id, name);
676
+ setAuthorNames((prev) => ({ ...prev, [id]: name }));
677
+ },
678
+ renameThread: (id: string, title: string) => controller.renameSession(id, title),
679
+ liveInput,
680
+ setCursor,
681
+ setInboxCursor,
682
+ setMode,
683
+ setRailTab,
684
+ setSelectedEntryId,
685
+ setFocusedAnnotationId,
686
+ setSelectedCurationId,
687
+ setPulsedAnnotationId,
688
+ selectCardFromDocument,
689
+ runEditorHandOff,
690
+ openCardEdit,
691
+ toggleDiffView: () => {
692
+ const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
836
693
 
837
- function defaultVerdict(s: ReviewSession): VerdictKind {
838
- return s.annotations.length || s.workingCopy !== undefined ? "request_changes" : "approve";
839
- }
694
+ setDiffView(next);
695
+ persistDiffView(next);
696
+ // every toggle names the new mode; picking split on a narrow pane also says it needs the wide layout
697
+ if (next === "stacked") controller.setStatus("stacked diff");
698
+ else if (workbench.zoomed) controller.setStatus("split diff");
699
+ else controller.setStatus("split diff shows when zoomed");
700
+ },
701
+ });
840
702
 
841
- function runStyle(r: StyleRun, d: DisplayBlock): { fg?: string; bg?: string; attributes?: number } {
842
- const headingFg = d.kind === "h1" ? T.text : d.kind === "h2" || d.kind === "h3" ? T.accent : undefined;
843
- const struck = d.type === "del";
844
- switch (r.role) {
845
- case "ins":
846
- return { fg: T.insFg };
847
- case "del":
848
- return { fg: T.delFg };
849
- case "mark-comment":
850
- return { fg: T.text, bg: T.markCommentBg };
851
- case "mark-suggestion":
852
- return { fg: T.text, bg: T.markSuggestionBg };
853
- case "mark-focus":
854
- return { fg: T.accentInk, bg: T.accent };
855
- case "kspan":
856
- return { fg: T.accentInk, bg: T.accent };
857
- default:
858
- return { fg: struck ? T.red : (headingFg ?? (d.kind === "code" ? T.textMuted : T.text)) };
859
- }
860
- }
703
+ const overlay = resolveOverlay(mode, completion.phase, walking);
704
+
705
+ const menuOwnsKeyboard = menuChromeOpen(menuDialog);
706
+ // an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
707
+ // from the thread view; the view suspends its own grammar meanwhile
708
+ const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
709
+
710
+ useKeyboard((key) => {
711
+ // The thread view owns the document grammar while active (its own
712
+ // useKeyboard handles marks, comments, and ctrl+q); the session chords
713
+ // (submit, share, edit, walk, the rail) resolve here, and the keymap only
714
+ // sees keys while an overlay or the menu owns them.
715
+ if (threadViewActive && !threadViewSuspended) {
716
+ const chord = resolveThreadChord(key, {
717
+ composing: threadComposing,
718
+ isOwner,
719
+ resolved,
720
+ treeActive: railTab === "tree",
721
+ isDiff,
722
+ });
723
+
724
+ if (chord) dispatch(chord);
725
+
726
+ return;
727
+ }
728
+ // A compose textarea owns the keyboard while open: let it receive the typed note instead of the
729
+ // global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
730
+ if (prototypeComposing || welcomeComposing) return;
731
+ if (menuDialog === "settings") return void handleSettingsKey(key.name);
732
+ if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
733
+ // the toast is non-modal: escape only dismisses it when nothing else owns
734
+ // escape, so an open overlay (compose, submit, prompt, walk) still cancels
735
+ if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
736
+ return controller.dismissToast();
737
+ const state = buildKeyState({
738
+ keys: keysRef.current,
739
+ observer,
740
+ isOwner,
741
+ overlay,
742
+ session,
743
+ isDiff,
744
+ mode,
745
+ resolved,
746
+ inbox,
747
+ focusedAnnotationId,
748
+ walk,
749
+ walkFileList,
750
+ rows,
751
+ cursor,
752
+ display,
753
+ });
754
+
755
+ keyBindings.setContext({
756
+ overlay: state.overlay,
757
+ spanMode: state.spanMode,
758
+ });
759
+ const action = keyBindings.resolveAction({
760
+ name: key.name,
761
+ shift: !!key.shift,
762
+ });
763
+
764
+ for (const intent of reduceKey(
765
+ state,
766
+ { name: key.name, shift: !!key.shift, meta: !!key.meta },
767
+ action,
768
+ ))
769
+ dispatch(intent);
770
+ });
771
+
772
+ // after every keyboard hook above, so their subscriptions precede the signal
773
+ useReadySignal(usableScreenReached(error, session, inbox), onReady);
774
+
775
+ // ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
776
+ // reused by the inbox and by plan/diff review so the two never drift ──
777
+ const menuChrome = (
778
+ <MenuChrome
779
+ menuDialog={menuDialog}
780
+ theme={theme}
781
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
782
+ settingsCategories={settingsCategories}
783
+ settingsValues={settingsValues}
784
+ settingsNav={settingsNav}
785
+ onCategorySelect={onCategorySelect}
786
+ cycleSetting={cycleSetting}
787
+ />
788
+ );
789
+
790
+ // ── render ──────────────────────────────────
791
+ if (error) return <ErrorScreen error={error} theme={theme} />;
792
+ if (!session)
793
+ return inbox ? (
794
+ <SlashSkillsContext.Provider value={skills}>
795
+ <NoThreadShell
796
+ rows={grouped.rows}
797
+ inboxCursor={inboxCursor}
798
+ mode={mode}
799
+ theme={theme}
800
+ controller={controller}
801
+ setMode={setMode}
802
+ menuChrome={menuChrome}
803
+ onOpenMenu={() => setMenuDialog("settings")}
804
+ sidebarOpen={sidebarOpen}
805
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
806
+ pinnedIds={pinnedIds}
807
+ onPin={togglePin}
808
+ onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
809
+ quickActions={quickActions}
810
+ onWelcomeComposingChange={setWelcomeComposing}
811
+ />
812
+ </SlashSkillsContext.Provider>
813
+ ) : (
814
+ <ConnectingScreen theme={theme} />
815
+ );
816
+
817
+ const activeSession = session;
818
+
819
+ if (isCompletionOverlayPhase(completion) && activeSession.verdict)
820
+ return (
821
+ <CompletionScreen
822
+ theme={theme}
823
+ session={activeSession}
824
+ verdict={activeSession.verdict.kind}
825
+ completion={completion}
826
+ status={status}
827
+ />
828
+ );
829
+
830
+ const diffFold: DiffFoldControls = {
831
+ isCollapsed: (file) => controller.isFileCollapsed(file),
832
+ isExpanded: (file) => controller.isFileExpanded(file),
833
+ canExpand: (file) => controller.canExpandFile(file),
834
+ onToggleCollapse: (file) =>
835
+ controller.setFileCollapsed(file, !controller.isFileCollapsed(file)),
836
+ onToggleExpand: (file) => controller.setFileExpanded(file, !controller.isFileExpanded(file)),
837
+ onCopyPath: (file) => controller.copyFilePath(file),
838
+ };
839
+ const submitConfirmState = buildSubmitConfirmState({
840
+ mode,
841
+ isDiff,
842
+ session: activeSession,
843
+ walkFileList,
844
+ viewedPaths,
845
+ liveInput,
846
+ setMode,
847
+ dispatch,
848
+ });
849
+ const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath } = buildRenderFlags({
850
+ session: activeSession,
851
+ isOwner,
852
+ isDiff,
853
+ isPixelPrototype,
854
+ resolved,
855
+ menuDialog,
856
+ resolvedIds,
857
+ });
858
+
859
+ const onEditRequest = (): void => {
860
+ // A share viewer/observer has no Edit affordance (the button is hidden), so
861
+ // this is owner-only; stay silent rather than nag if it is ever reached.
862
+ if (!isOwner) return;
863
+ if (resolved) return controller.setStatus("review submitted - read-only");
864
+ runEditorHandOff();
865
+ };
866
+
867
+ // clicking the header Share button: publish the plan, copy the ssh line
868
+ const onShareRequest = (): void => {
869
+ if (!isOwner) return controller.setStatus("only the plan owner can share");
870
+ controller.share();
871
+ };
872
+
873
+ // clicking the rail Submit button: same read-only answer as the submit key
874
+ const onSubmitRequest = (): void => {
875
+ if (observer) return controller.setStatus("observer - read-only");
876
+ if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
877
+ if (resolved) return;
878
+ dispatch({ type: "openSubmit" });
879
+ };
861
880
 
862
- function truncate(s: string, n: number): string {
863
- const one = s.replace(/\n/g, " ");
864
- return one.length > n ? one.slice(0, n - 1) + "…" : one;
881
+ return (
882
+ <SlashSkillsContext.Provider value={skills}>
883
+ <ThemeProvider theme={theme}>
884
+ <AppShell
885
+ sidebarOpen={sidebarOpen}
886
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
887
+ onOpenMenu={() => setMenuDialog("settings")}
888
+ threadsPanel={
889
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
890
+ <ThreadTree
891
+ rows={grouped.rows}
892
+ cursor={inboxCursor}
893
+ activeId={activeSession.id}
894
+ pinnedIds={pinnedIds}
895
+ width={30}
896
+ onSelect={(id) => controller.open(id)}
897
+ onPin={togglePin}
898
+ onRename={(id, title) =>
899
+ setMode({ type: "renameThread", sessionId: id, text: title })
900
+ }
901
+ theme={theme}
902
+ />
903
+ </scrollbox>
904
+ }
905
+ threadTitle={threadTitle(activeSession)}
906
+ threadActions={
907
+ showOwnerActions ? (
908
+ <Toolbar>
909
+ <Button onPress={onEditRequest} theme={theme}>
910
+ {" edit "}
911
+ </Button>
912
+ <Button onPress={onShareRequest} theme={theme}>
913
+ {" share "}
914
+ </Button>
915
+ </Toolbar>
916
+ ) : undefined
917
+ }
918
+ threadPanel={
919
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
920
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
921
+ {isPixelPrototype ? (
922
+ <PrototypePixels
923
+ prototypePath={prototypePath}
924
+ canComment={prototypeCanComment}
925
+ onCommentElement={onCommentPrototype}
926
+ onComposingChange={setPrototypeComposing}
927
+ hidden={chromeHidden}
928
+ />
929
+ ) : isDiff ? (
930
+ <box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
931
+ <text fg={theme.textDim}>review the changes on the right</text>
932
+ </box>
933
+ ) : (
934
+ <ThreadView
935
+ session={activeSession}
936
+ suspended={threadViewSuspended}
937
+ editOrphanCount={editOrphanCount}
938
+ onComposingChange={setThreadComposing}
939
+ resolved={resolved}
940
+ onObserverBlocked={(reason) =>
941
+ controller.setStatus(
942
+ reason === "observer"
943
+ ? "observer - read-only"
944
+ : "review submitted - read-only",
945
+ )
946
+ }
947
+ onCursorChange={setCursor}
948
+ focusedAnnotationId={focusedAnnotationId}
949
+ onFocusAnnotation={setFocusedAnnotationId}
950
+ display={display}
951
+ marks={marks}
952
+ quickActions={quickActions}
953
+ observer={observer}
954
+ onAnnotate={(span, body) =>
955
+ void controller.annotate(
956
+ "comment",
957
+ span.start.blockIndex,
958
+ span.start.char,
959
+ span.end.char,
960
+ body,
961
+ span.end.blockIndex,
962
+ )
963
+ }
964
+ onReply={(rootAnnotationId, body) =>
965
+ void controller.reply(rootAnnotationId, body)
966
+ }
967
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
968
+ onExit={() => onExit?.(0)}
969
+ />
970
+ )}
971
+ </box>
972
+ <ThreadFooter
973
+ repo={projectName(activeSession.workspace)}
974
+ branch={activeSession.workspace.branch}
975
+ onSubmit={onSubmitRequest}
976
+ canSubmit={canSubmitReview(isOwner, resolved, observer)}
977
+ theme={theme}
978
+ />
979
+ </box>
980
+ }
981
+ changesOpen={workbench.changesOpen}
982
+ projectOpen={workbench.projectOpen}
983
+ onToggleChanges={workbench.toggleChanges}
984
+ onToggleProject={workbench.toggleProject}
985
+ onToggleRight={workbench.toggleRight}
986
+ projectMode={workbench.projectMode}
987
+ zoomHideThread={workbench.zoomed}
988
+ changesPanel={
989
+ <EditorGrid
990
+ tree={workbench.grid}
991
+ focusedGroupId={workbench.activeGroup}
992
+ commentCounts={diffCommentCounts}
993
+ onFocusGroup={workbench.focusGroup}
994
+ onActivateTab={workbench.activate}
995
+ onCloseTab={workbench.close}
996
+ onSplit={workbench.split}
997
+ onZoom={workbench.toggleZoom}
998
+ zoomed={workbench.zoomed}
999
+ renderTab={(tab, groupFocused) => (
1000
+ <GridTabContent
1001
+ tab={tab}
1002
+ rows={rows}
1003
+ surface={{
1004
+ session: activeSession,
1005
+ quickActions,
1006
+ observer,
1007
+ commentsEnabled: true,
1008
+ resolved,
1009
+ suspended: threadViewSuspended || !groupFocused,
1010
+ onComposingChange: setThreadComposing,
1011
+ onObserverBlocked: (reason) =>
1012
+ controller.setStatus(
1013
+ reason === "observer"
1014
+ ? "observer - read-only"
1015
+ : "review submitted - read-only",
1016
+ ),
1017
+ onCursorChange: setCursor,
1018
+ focusedAnnotationId,
1019
+ onFocusAnnotation: setFocusedAnnotationId,
1020
+ // a diff thread's Changes view is the artifact itself; any other thread's is the
1021
+ // live working-tree diff, so its notes carry a file target and anchor there
1022
+ onAnnotate: (span, body) =>
1023
+ void controller.annotate(
1024
+ "comment",
1025
+ span.start.blockIndex,
1026
+ span.start.char,
1027
+ span.end.char,
1028
+ body,
1029
+ span.end.blockIndex,
1030
+ isDiff ? { kind: "artifact" } : { kind: "file", path: "", rev: "worktree" },
1031
+ ),
1032
+ onReply: (rootAnnotationId, body) =>
1033
+ void controller.reply(rootAnnotationId, body),
1034
+ onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1035
+ onExit: () => onExit?.(0),
1036
+ }}
1037
+ rejectedRows={rejectedRows}
1038
+ fold={diffFold}
1039
+ fileStats={controller.fileStats()}
1040
+ split={diffView === "split" && workbench.zoomed}
1041
+ dimmed={walking}
1042
+ readFile={(path) => controller.repoReadFile(path)}
1043
+ onAddFileComment={(path, anchor, body) =>
1044
+ void controller.addComment(
1045
+ anchor,
1046
+ { kind: "file", path, rev: "worktree" },
1047
+ body,
1048
+ )
1049
+ }
1050
+ theme={theme}
1051
+ />
1052
+ )}
1053
+ theme={theme}
1054
+ />
1055
+ }
1056
+ projectPanel={
1057
+ <ProjectPanelBody
1058
+ mode={workbench.projectMode}
1059
+ loadChanges={() => controller.repoChanges()}
1060
+ loadProjectFiles={() => controller.repoFiles()}
1061
+ reloadKey={activeSession.id}
1062
+ // the Changes navigator always opens a changed file as a diff - a diff review shows its
1063
+ // captured snapshot, every other thread the live working-tree diff
1064
+ onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1065
+ onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1066
+ commentCounts={diffCommentCounts}
1067
+ theme={theme}
1068
+ />
1069
+ }
1070
+ theme={theme}
1071
+ >
1072
+ <TrailingOverlays
1073
+ walking={walking}
1074
+ walk={walk}
1075
+ walkFileList={walkFileList}
1076
+ viewedPaths={viewedPaths}
1077
+ session={activeSession}
1078
+ terminalWidth={terminalWidth}
1079
+ theme={theme}
1080
+ mode={mode}
1081
+ toast={toast}
1082
+ setMode={setMode}
1083
+ dispatch={dispatch}
1084
+ />
1085
+ {submitConfirmState !== null ? (
1086
+ <box
1087
+ style={{
1088
+ position: "absolute",
1089
+ left: 0,
1090
+ top: 0,
1091
+ width: "100%",
1092
+ height: "100%",
1093
+ justifyContent: "center",
1094
+ alignItems: "center",
1095
+ }}
1096
+ >
1097
+ <ConfirmCard {...submitConfirmState} theme={theme} />
1098
+ </box>
1099
+ ) : null}
1100
+ {menuChrome}
1101
+ </AppShell>
1102
+ </ThemeProvider>
1103
+ </SlashSkillsContext.Provider>
1104
+ );
865
1105
  }