@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1043 -798
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +495 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +290 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +153 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
package/src/App.tsx
CHANGED
|
@@ -1,865 +1,1110 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { useKeyboard } from "@opentui/react";
|
|
10
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
type
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from "./
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
<
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
<
|
|
492
|
-
{
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
545
|
-
const
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
333
|
+
alive = false;
|
|
555
334
|
};
|
|
556
|
-
}, [
|
|
557
|
-
|
|
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
|
-
<
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
/**
|
|
596
|
-
function
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
);
|
|
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();
|
|
369
|
+
|
|
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
|
+
];
|
|
628
382
|
}
|
|
629
383
|
|
|
630
|
-
function
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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],
|
|
641
413
|
);
|
|
642
|
-
}
|
|
643
414
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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>
|
|
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],
|
|
662
426
|
);
|
|
663
|
-
}
|
|
427
|
+
const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
|
|
428
|
+
useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
664
429
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
+
[],
|
|
681
518
|
);
|
|
682
|
-
}
|
|
683
519
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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],
|
|
707
580
|
);
|
|
708
|
-
|
|
581
|
+
/** Annotation ids whose anchor resolved against the working copy. */
|
|
582
|
+
const resolvedIds = useMemo(() => {
|
|
583
|
+
const ids = new Set<string>();
|
|
709
584
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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>
|
|
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,
|
|
768
598
|
);
|
|
769
|
-
|
|
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]);
|
|
770
619
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
620
|
+
// ── selection symmetry: one selected id, both sides ──
|
|
621
|
+
const selectCardFromDocument = (annotationId: string): void => {
|
|
622
|
+
setFocusedAnnotationId(annotationId);
|
|
623
|
+
};
|
|
624
|
+
|
|
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);
|
|
628
|
+
|
|
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);
|
|
634
|
+
|
|
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
|
+
};
|
|
645
|
+
|
|
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
|
+
};
|
|
655
|
+
|
|
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";
|
|
693
|
+
|
|
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
|
+
});
|
|
702
|
+
|
|
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
|
+
/>
|
|
809
788
|
);
|
|
810
|
-
}
|
|
811
789
|
|
|
812
|
-
// ──
|
|
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
|
+
);
|
|
813
816
|
|
|
814
|
-
|
|
815
|
-
if (d.kind === "li") return "- ";
|
|
816
|
-
if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
|
|
817
|
-
if (d.kind === "quote") return "▏ ";
|
|
818
|
-
return "";
|
|
819
|
-
}
|
|
817
|
+
const activeSession = session;
|
|
820
818
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
+
);
|
|
824
829
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
+
});
|
|
828
858
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
};
|
|
832
866
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
+
};
|
|
836
872
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
};
|
|
840
880
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
}
|
|
881
|
+
const threadFooter = (
|
|
882
|
+
<ThreadFooter
|
|
883
|
+
repo={projectName(activeSession.workspace)}
|
|
884
|
+
branch={activeSession.workspace.branch}
|
|
885
|
+
onSubmit={onSubmitRequest}
|
|
886
|
+
canSubmit={canSubmitReview(isOwner, resolved, observer)}
|
|
887
|
+
theme={theme}
|
|
888
|
+
/>
|
|
889
|
+
);
|
|
861
890
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
891
|
+
return (
|
|
892
|
+
<SlashSkillsContext.Provider value={skills}>
|
|
893
|
+
<ThemeProvider theme={theme}>
|
|
894
|
+
<AppShell
|
|
895
|
+
sidebarOpen={sidebarOpen}
|
|
896
|
+
onToggleSidebar={() => setSidebarOpen((open) => !open)}
|
|
897
|
+
onOpenMenu={() => setMenuDialog("settings")}
|
|
898
|
+
threadsPanel={
|
|
899
|
+
<scrollbox style={{ flexGrow: 1 }} focused={false}>
|
|
900
|
+
<ThreadTree
|
|
901
|
+
rows={grouped.rows}
|
|
902
|
+
cursor={inboxCursor}
|
|
903
|
+
activeId={activeSession.id}
|
|
904
|
+
pinnedIds={pinnedIds}
|
|
905
|
+
width={30}
|
|
906
|
+
onSelect={(id) => controller.open(id)}
|
|
907
|
+
onPin={togglePin}
|
|
908
|
+
onRename={(id, title) =>
|
|
909
|
+
setMode({ type: "renameThread", sessionId: id, text: title })
|
|
910
|
+
}
|
|
911
|
+
theme={theme}
|
|
912
|
+
/>
|
|
913
|
+
</scrollbox>
|
|
914
|
+
}
|
|
915
|
+
threadTitle={threadTitle(activeSession)}
|
|
916
|
+
threadActions={
|
|
917
|
+
showOwnerActions ? (
|
|
918
|
+
<Toolbar>
|
|
919
|
+
<Button onPress={onEditRequest} theme={theme}>
|
|
920
|
+
{" edit "}
|
|
921
|
+
</Button>
|
|
922
|
+
<Button onPress={onShareRequest} theme={theme}>
|
|
923
|
+
{" share "}
|
|
924
|
+
</Button>
|
|
925
|
+
</Toolbar>
|
|
926
|
+
) : undefined
|
|
927
|
+
}
|
|
928
|
+
threadPanel={
|
|
929
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
930
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
931
|
+
{isPixelPrototype ? (
|
|
932
|
+
<PrototypePixels
|
|
933
|
+
prototypePath={prototypePath}
|
|
934
|
+
canComment={prototypeCanComment}
|
|
935
|
+
onCommentElement={onCommentPrototype}
|
|
936
|
+
onComposingChange={setPrototypeComposing}
|
|
937
|
+
hidden={chromeHidden}
|
|
938
|
+
/>
|
|
939
|
+
) : isDiff ? (
|
|
940
|
+
<box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
|
|
941
|
+
<text fg={theme.textDim}>review the changes on the right</text>
|
|
942
|
+
</box>
|
|
943
|
+
) : (
|
|
944
|
+
<ThreadView
|
|
945
|
+
session={activeSession}
|
|
946
|
+
suspended={threadViewSuspended}
|
|
947
|
+
editOrphanCount={editOrphanCount}
|
|
948
|
+
onComposingChange={setThreadComposing}
|
|
949
|
+
resolved={resolved}
|
|
950
|
+
onObserverBlocked={(reason) =>
|
|
951
|
+
controller.setStatus(
|
|
952
|
+
reason === "observer"
|
|
953
|
+
? "observer - read-only"
|
|
954
|
+
: "review submitted - read-only",
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
onCursorChange={setCursor}
|
|
958
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
959
|
+
onFocusAnnotation={setFocusedAnnotationId}
|
|
960
|
+
display={display}
|
|
961
|
+
marks={marks}
|
|
962
|
+
quickActions={quickActions}
|
|
963
|
+
observer={observer}
|
|
964
|
+
onAnnotate={(span, body) =>
|
|
965
|
+
void controller.annotate(
|
|
966
|
+
"comment",
|
|
967
|
+
span.start.blockIndex,
|
|
968
|
+
span.start.char,
|
|
969
|
+
span.end.char,
|
|
970
|
+
body,
|
|
971
|
+
span.end.blockIndex,
|
|
972
|
+
)
|
|
973
|
+
}
|
|
974
|
+
onReply={(rootAnnotationId, body) =>
|
|
975
|
+
void controller.reply(rootAnnotationId, body)
|
|
976
|
+
}
|
|
977
|
+
onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
|
|
978
|
+
onExit={() => onExit?.(0)}
|
|
979
|
+
/>
|
|
980
|
+
)}
|
|
981
|
+
</box>
|
|
982
|
+
{threadFooter}
|
|
983
|
+
</box>
|
|
984
|
+
}
|
|
985
|
+
changesOpen={workbench.changesOpen}
|
|
986
|
+
projectOpen={workbench.projectOpen}
|
|
987
|
+
onToggleChanges={workbench.toggleChanges}
|
|
988
|
+
onToggleProject={workbench.toggleProject}
|
|
989
|
+
onToggleRight={workbench.toggleRight}
|
|
990
|
+
projectMode={workbench.projectMode}
|
|
991
|
+
zoomHideThread={workbench.zoomed}
|
|
992
|
+
changesFooter={threadFooter}
|
|
993
|
+
changesPanel={
|
|
994
|
+
<EditorGrid
|
|
995
|
+
tree={workbench.grid}
|
|
996
|
+
focusedGroupId={workbench.activeGroup}
|
|
997
|
+
commentCounts={diffCommentCounts}
|
|
998
|
+
onFocusGroup={workbench.focusGroup}
|
|
999
|
+
onActivateTab={workbench.activate}
|
|
1000
|
+
onCloseTab={workbench.close}
|
|
1001
|
+
onSplit={workbench.split}
|
|
1002
|
+
onZoom={workbench.toggleZoom}
|
|
1003
|
+
zoomed={workbench.zoomed}
|
|
1004
|
+
renderTab={(tab, groupFocused) => (
|
|
1005
|
+
<GridTabContent
|
|
1006
|
+
tab={tab}
|
|
1007
|
+
rows={rows}
|
|
1008
|
+
surface={{
|
|
1009
|
+
session: activeSession,
|
|
1010
|
+
quickActions,
|
|
1011
|
+
observer,
|
|
1012
|
+
commentsEnabled: true,
|
|
1013
|
+
resolved,
|
|
1014
|
+
suspended: threadViewSuspended || !groupFocused,
|
|
1015
|
+
onComposingChange: setThreadComposing,
|
|
1016
|
+
onObserverBlocked: (reason) =>
|
|
1017
|
+
controller.setStatus(
|
|
1018
|
+
reason === "observer"
|
|
1019
|
+
? "observer - read-only"
|
|
1020
|
+
: "review submitted - read-only",
|
|
1021
|
+
),
|
|
1022
|
+
onCursorChange: setCursor,
|
|
1023
|
+
focusedAnnotationId,
|
|
1024
|
+
onFocusAnnotation: setFocusedAnnotationId,
|
|
1025
|
+
// a diff thread's Changes view is the artifact itself; any other thread's is the
|
|
1026
|
+
// live working-tree diff, so its notes carry a file target and anchor there
|
|
1027
|
+
onAnnotate: (span, body) =>
|
|
1028
|
+
void controller.annotate(
|
|
1029
|
+
"comment",
|
|
1030
|
+
span.start.blockIndex,
|
|
1031
|
+
span.start.char,
|
|
1032
|
+
span.end.char,
|
|
1033
|
+
body,
|
|
1034
|
+
span.end.blockIndex,
|
|
1035
|
+
isDiff ? { kind: "artifact" } : { kind: "file", path: "", rev: "worktree" },
|
|
1036
|
+
),
|
|
1037
|
+
onReply: (rootAnnotationId, body) =>
|
|
1038
|
+
void controller.reply(rootAnnotationId, body),
|
|
1039
|
+
onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
|
|
1040
|
+
onExit: () => onExit?.(0),
|
|
1041
|
+
}}
|
|
1042
|
+
rejectedRows={rejectedRows}
|
|
1043
|
+
fold={diffFold}
|
|
1044
|
+
fileStats={controller.fileStats()}
|
|
1045
|
+
split={diffView === "split" && workbench.zoomed}
|
|
1046
|
+
dimmed={walking}
|
|
1047
|
+
readFile={(path) => controller.repoReadFile(path)}
|
|
1048
|
+
onAddFileComment={(path, anchor, body) =>
|
|
1049
|
+
void controller.addComment(
|
|
1050
|
+
anchor,
|
|
1051
|
+
{ kind: "file", path, rev: "worktree" },
|
|
1052
|
+
body,
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
theme={theme}
|
|
1056
|
+
/>
|
|
1057
|
+
)}
|
|
1058
|
+
theme={theme}
|
|
1059
|
+
/>
|
|
1060
|
+
}
|
|
1061
|
+
projectPanel={
|
|
1062
|
+
<ProjectPanelBody
|
|
1063
|
+
mode={workbench.projectMode}
|
|
1064
|
+
loadChanges={() => controller.repoChanges()}
|
|
1065
|
+
loadProjectFiles={() => controller.repoFiles()}
|
|
1066
|
+
reloadKey={activeSession.id}
|
|
1067
|
+
// the Changes navigator always opens a changed file as a diff - a diff review shows its
|
|
1068
|
+
// captured snapshot, every other thread the live working-tree diff
|
|
1069
|
+
onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
|
|
1070
|
+
onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
|
|
1071
|
+
commentCounts={diffCommentCounts}
|
|
1072
|
+
theme={theme}
|
|
1073
|
+
/>
|
|
1074
|
+
}
|
|
1075
|
+
theme={theme}
|
|
1076
|
+
>
|
|
1077
|
+
<TrailingOverlays
|
|
1078
|
+
walking={walking}
|
|
1079
|
+
walk={walk}
|
|
1080
|
+
walkFileList={walkFileList}
|
|
1081
|
+
viewedPaths={viewedPaths}
|
|
1082
|
+
session={activeSession}
|
|
1083
|
+
terminalWidth={terminalWidth}
|
|
1084
|
+
theme={theme}
|
|
1085
|
+
mode={mode}
|
|
1086
|
+
toast={toast}
|
|
1087
|
+
setMode={setMode}
|
|
1088
|
+
dispatch={dispatch}
|
|
1089
|
+
/>
|
|
1090
|
+
{submitConfirmState !== null ? (
|
|
1091
|
+
<box
|
|
1092
|
+
style={{
|
|
1093
|
+
position: "absolute",
|
|
1094
|
+
left: 0,
|
|
1095
|
+
top: 0,
|
|
1096
|
+
width: "100%",
|
|
1097
|
+
height: "100%",
|
|
1098
|
+
justifyContent: "center",
|
|
1099
|
+
alignItems: "center",
|
|
1100
|
+
}}
|
|
1101
|
+
>
|
|
1102
|
+
<ConfirmCard {...submitConfirmState} theme={theme} />
|
|
1103
|
+
</box>
|
|
1104
|
+
) : null}
|
|
1105
|
+
{menuChrome}
|
|
1106
|
+
</AppShell>
|
|
1107
|
+
</ThemeProvider>
|
|
1108
|
+
</SlashSkillsContext.Provider>
|
|
1109
|
+
);
|
|
865
1110
|
}
|