@cueloop/client 0.1.0-alpha.0 → 0.1.0-alpha.100
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 +41 -27
- package/src/annotations/annotation-palette.ts +62 -0
- package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +364 -0
- package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
- package/src/annotations/discussions.ts +77 -0
- package/src/annotations/thread-selection.test.ts +210 -0
- package/src/annotations/thread-selection.ts +242 -0
- package/src/annotations/use-annotation-surface.tsx +1238 -0
- package/src/app/App.appearance.test.tsx +53 -0
- package/src/app/App.autoclose.test.tsx +144 -0
- package/src/app/App.deleted-scroll.test.tsx +187 -0
- package/src/app/App.diff.test.tsx +313 -0
- package/src/app/App.export.test.tsx +105 -0
- package/src/app/App.focus.test.ts +89 -0
- package/src/app/App.identity.test.tsx +84 -0
- package/src/app/App.plan-render.test.tsx +209 -0
- package/src/app/App.plan-review.test.tsx +428 -0
- package/src/app/App.prototype-markdown.test.tsx +128 -0
- package/src/app/App.prototype.test.tsx +176 -0
- package/src/app/App.readonly.test.tsx +210 -0
- package/src/app/App.regression.test.tsx +99 -0
- package/src/app/App.rendering.test.tsx +134 -0
- package/src/app/App.share-dialog.test.tsx +177 -0
- package/src/app/App.submit-confirm.test.tsx +267 -0
- package/src/app/App.test.tsx +496 -0
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.toast.test.tsx +124 -0
- package/src/app/App.tsx +1650 -0
- package/src/app/App.visual.test.tsx +59 -0
- package/src/app/App.walk.test.tsx +299 -0
- package/src/app/App.workbench.test.tsx +1149 -0
- package/src/app/app-key-state.ts +61 -0
- package/src/app/app-screens.tsx +606 -0
- package/src/app/app-view-model.ts +168 -0
- package/src/app/components/AppHeader.stories.tsx +50 -0
- package/src/app/components/AppHeader.tsx +81 -0
- package/src/app/components/AppShell.brand.test.tsx +58 -0
- package/src/app/components/AppShell.extensions.test.tsx +191 -0
- package/src/app/components/AppShell.stories.tsx +89 -0
- package/src/app/components/AppShell.tsx +358 -0
- package/src/app/components/AppShell.zoom.test.tsx +149 -0
- package/src/app/components/Breadcrumb.stories.tsx +19 -0
- package/src/app/components/Breadcrumb.tsx +45 -0
- package/src/app/components/MenuBar.stories.tsx +28 -0
- package/src/app/components/MenuBar.tsx +44 -0
- package/src/app/components/PaneGrid.stories.tsx +84 -0
- package/src/app/components/PaneGrid.tsx +128 -0
- package/src/app/components/PanelColumn.stories.tsx +25 -0
- package/src/app/components/PanelColumn.tsx +93 -0
- package/src/app/components/ShellHeader.stories.tsx +50 -0
- package/src/app/components/ShellHeader.tsx +137 -0
- package/src/app/components/WorkspacePanels.stories.tsx +36 -0
- package/src/app/components/WorkspacePanels.tsx +120 -0
- package/src/app/components/pages/Workspace.stories.tsx +181 -0
- package/src/app/intent-dispatch.test.ts +728 -0
- package/src/app/intent-dispatch.ts +687 -0
- package/src/app/launch-layout.test.ts +78 -0
- package/src/app/launch-layout.ts +57 -0
- package/src/app/ready-signal.test.ts +85 -0
- package/src/app/ready-signal.ts +52 -0
- package/src/app/run.ts +109 -0
- package/src/{serve.test.ts → app/serve.test.ts} +24 -12
- package/src/app/serve.ts +111 -0
- package/src/app/version.ts +5 -0
- package/src/appearance/components/theme-context.stories.tsx +46 -0
- package/src/appearance/components/theme-context.tsx +37 -0
- package/src/appearance/theme-presets.test.ts +51 -0
- package/src/appearance/theme-presets.ts +219 -0
- package/src/appearance/theme.ts +112 -0
- package/src/appearance/visual/contrast.test.ts +35 -0
- package/src/appearance/visual/contrast.ts +33 -0
- package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
- package/src/diff/components/AnnotatableFileView.tsx +141 -0
- package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
- package/src/diff/components/DiffContentView.split.test.tsx +140 -0
- package/src/diff/components/DiffContentView.stories.tsx +80 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/diff/components/DiffContentView.tsx +1055 -0
- package/src/diff/components/WalkWizard.stories.tsx +100 -0
- package/src/diff/components/WalkWizard.tsx +146 -0
- package/src/diff/components/diff-content-view-layout.test.ts +198 -0
- package/src/diff/components/diff-content-view-layout.ts +84 -0
- package/src/diff/diff-fold.test.ts +78 -0
- package/src/diff/diff-fold.ts +126 -0
- package/src/diff/diff-hunk-curate.test.ts +315 -0
- package/src/diff/diff-hunk-curate.ts +152 -0
- package/src/diff/diff-intraline.test.ts +239 -0
- package/src/diff/diff-intraline.ts +238 -0
- package/src/diff/diff-syntax.test.ts +125 -0
- package/src/diff/diff-syntax.ts +186 -0
- package/src/diff/split-diff.test.ts +110 -0
- package/src/diff/split-diff.ts +144 -0
- package/src/diff/view-diff.test.ts +325 -0
- package/src/diff/view-diff.ts +522 -0
- package/src/diff/walk.test.ts +121 -0
- package/src/diff/walk.ts +73 -0
- package/src/index.ts +31 -2
- package/src/integrations/attribution.test.ts +31 -0
- package/src/integrations/attribution.ts +42 -0
- package/src/integrations/client-extension-registry.test.tsx +61 -0
- package/src/integrations/client-extension-registry.ts +159 -0
- package/src/integrations/clipboard.test.ts +23 -0
- package/src/integrations/clipboard.ts +33 -0
- package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
- package/src/integrations/components/ExtensionZone.tsx +145 -0
- package/src/integrations/components/ShareDialog.stories.tsx +121 -0
- package/src/integrations/components/ShareDialog.tsx +550 -0
- package/src/integrations/components/share-dialog-store.ts +55 -0
- package/src/integrations/editor.test.ts +149 -0
- package/src/integrations/editor.ts +160 -0
- package/src/integrations/github-identity.test.ts +58 -0
- package/src/integrations/github-identity.ts +41 -0
- package/src/integrations/herdr.test.ts +43 -0
- package/src/integrations/herdr.ts +38 -0
- package/src/integrations/integrations.test.ts +41 -0
- package/src/integrations/integrations.ts +32 -0
- package/src/integrations/pasted-image.test.ts +67 -0
- package/src/integrations/pasted-image.ts +60 -0
- package/src/integrations/share.ts +238 -0
- package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
- package/src/keyboard/components/CompletionOverlay.tsx +75 -0
- package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
- package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
- package/src/keyboard/components/HandleChipsInput.tsx +143 -0
- package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
- package/src/keyboard/components/KeybindsDialog.tsx +103 -0
- package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
- package/src/keyboard/components/NavModeHint.tsx +31 -0
- package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
- package/src/keyboard/components/SlashComposer.tsx +87 -0
- package/src/keyboard/key-bindings.test.ts +156 -0
- package/src/keyboard/key-bindings.ts +459 -0
- package/src/keyboard/keymap.test.ts +611 -0
- package/src/keyboard/keymap.ts +395 -0
- package/src/keyboard/skills.test.ts +74 -0
- package/src/keyboard/skills.ts +75 -0
- package/src/keyboard/slash-palette.test.ts +103 -0
- package/src/keyboard/slash-palette.ts +129 -0
- package/src/keyboard/thread-chords.test.ts +79 -0
- package/src/keyboard/thread-chords.ts +163 -0
- package/src/keyboard/use-shared-keyboard.ts +55 -0
- package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
- package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
- package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
- package/src/markdown/components/ThreadView.stories.tsx +26 -0
- package/src/markdown/components/ThreadView.tsx +560 -0
- package/src/markdown/mark-runs.ts +121 -0
- package/src/markdown/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown/markdown-grid.test.ts +101 -0
- package/src/markdown/markdown-grid.ts +203 -0
- package/src/markdown/markdown-highlight.test.ts +61 -0
- package/src/markdown/markdown-highlight.ts +124 -0
- package/src/markdown/thread-code-syntax.ts +45 -0
- package/src/markdown/use-thread-body-editing.tsx +54 -0
- package/src/markdown/view-plan.regression.test.ts +60 -0
- package/src/markdown/view-plan.test.ts +545 -0
- package/src/markdown/view-plan.ts +1009 -0
- package/src/performance/otlp-trace.test.ts +119 -0
- package/src/performance/otlp-trace.ts +134 -0
- package/src/performance/perf-report.ts +18 -0
- package/src/performance/perf-timings.test.ts +18 -0
- package/src/performance/perf-timings.ts +39 -0
- package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
- package/src/prototype/components/PrototypeContentView.tsx +442 -0
- package/src/prototype/prototype-browser.test.ts +81 -0
- package/src/prototype/prototype-browser.ts +321 -0
- package/src/prototype/prototype-pixels.tsx +35 -0
- package/src/settings/components/SettingsDialog.stories.tsx +117 -0
- package/src/settings/components/SettingsDialog.tsx +183 -0
- package/src/settings/components/SettingsRows.stories.tsx +18 -0
- package/src/settings/components/SettingsRows.tsx +112 -0
- package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
- package/src/settings/components/quick-actions-editor.test.tsx +120 -0
- package/src/settings/components/quick-actions-editor.tsx +222 -0
- package/src/settings/config-identity.test.ts +78 -0
- package/src/settings/config.test.ts +676 -0
- package/src/settings/config.ts +628 -0
- package/src/settings/use-settings-dialog.tsx +351 -0
- package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
- package/src/stories/stories-app.tsx +196 -0
- package/src/stories/stories.test.tsx +74 -0
- package/src/stories/story-fixtures.ts +102 -0
- package/src/stories/story-tree.test.ts +108 -0
- package/src/stories/story.ts +154 -0
- package/src/terminal/ansi-screen.test.ts +28 -0
- package/src/terminal/ansi-screen.ts +72 -0
- package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
- package/src/terminal/components/AnsiScreen.tsx +28 -0
- package/src/terminal/components/terminal-pane.test.tsx +40 -0
- package/src/terminal/components/terminal-pane.ts +196 -0
- package/src/terminal/ghostty-terminal.test.ts +85 -0
- package/src/terminal/ghostty-terminal.ts +197 -0
- package/src/terminal/kitty-image.test.ts +66 -0
- package/src/terminal/kitty-image.ts +137 -0
- package/src/terminal/pty.test.ts +81 -0
- package/src/terminal/pty.ts +246 -0
- package/src/testing/test-support.test.ts +264 -0
- package/src/testing/test-support.ts +367 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +829 -0
- package/src/thread/components/AgentThreadPane.tsx +367 -0
- package/src/thread/components/ThreadFooter.stories.tsx +30 -0
- package/src/thread/components/ThreadFooter.test.tsx +49 -0
- package/src/thread/components/ThreadFooter.tsx +77 -0
- package/src/thread/components/ThreadTree.stories.tsx +46 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +267 -0
- package/src/thread/components/thread-tree.test.ts +137 -0
- package/src/thread/components/thread-tree.ts +112 -0
- package/src/thread/thread-controller.curate.test.ts +502 -0
- package/src/thread/thread-controller.derivation.test.ts +62 -0
- package/src/thread/thread-controller.live-diff.test.ts +357 -0
- package/src/thread/thread-controller.optimistic.test.ts +69 -0
- package/src/thread/thread-controller.share.test.ts +573 -0
- package/src/thread/thread-controller.switch-cache.test.ts +174 -0
- package/src/thread/thread-controller.tree.test.ts +263 -0
- package/src/thread/thread-controller.ts +2018 -0
- package/src/thread/tree-view.test.ts +128 -0
- package/src/thread/tree-view.ts +146 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +111 -0
- package/src/ui/components/CodeBlock.stories.tsx +42 -0
- package/src/ui/components/CodeBlock.tsx +80 -0
- package/src/ui/components/ConfirmCard.stories.tsx +37 -0
- package/src/ui/components/ConfirmCard.tsx +151 -0
- package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
- package/src/ui/components/ConfirmDialog.tsx +72 -0
- package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
- package/src/ui/components/OverlayScrollbar.tsx +129 -0
- package/src/ui/components/PromptDialog.stories.tsx +36 -0
- package/src/ui/components/PromptDialog.test.tsx +65 -0
- package/src/ui/components/PromptDialog.tsx +99 -0
- package/src/ui/components/RootOverlay.stories.tsx +45 -0
- package/src/ui/components/RootOverlay.tsx +49 -0
- package/src/ui/components/ScrollArea.stories.tsx +20 -0
- package/src/ui/components/ScrollArea.tsx +61 -0
- package/src/ui/components/Toast.stories.tsx +19 -0
- package/src/ui/components/Toast.tsx +54 -0
- package/src/ui/components/Tooltip.stories.tsx +26 -0
- package/src/ui/components/Tooltip.tsx +72 -0
- package/src/ui/components/menu-control.stories.tsx +35 -0
- package/src/ui/components/menu-control.tsx +43 -0
- package/src/ui/components/primitives/Button.stories.tsx +32 -0
- package/src/ui/components/primitives/Button.tsx +57 -0
- package/src/ui/components/primitives/Card.stories.tsx +25 -0
- package/src/ui/components/primitives/Card.tsx +62 -0
- package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
- package/src/ui/components/primitives/Dialog.test.tsx +38 -0
- package/src/ui/components/primitives/Dialog.tsx +69 -0
- package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/ui/components/primitives/DialogActions.tsx +39 -0
- package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
- package/src/ui/components/primitives/IconButton.tsx +58 -0
- package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/ui/components/primitives/StatusBar.tsx +23 -0
- package/src/ui/components/primitives/Switch.stories.tsx +17 -0
- package/src/ui/components/primitives/Switch.tsx +32 -0
- package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
- package/src/ui/components/primitives/Tabs.tsx +101 -0
- package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/ui/components/primitives/Toolbar.tsx +14 -0
- package/src/ui/components/primitives/Tree.stories.tsx +124 -0
- package/src/ui/components/primitives/Tree.tsx +133 -0
- package/src/ui/components/primitives/frame.test.ts +12 -0
- package/src/ui/components/primitives/frame.ts +17 -0
- package/src/ui/components/primitives/icons.ts +61 -0
- package/src/ui/components/primitives/tree-model.test.ts +99 -0
- package/src/ui/components/primitives/tree-model.ts +137 -0
- package/src/ui/components/syntax-highlight.ts +133 -0
- package/src/ui/components/truncate-text.ts +7 -0
- package/src/ui/components/truncate-title.test.ts +31 -0
- package/src/ui/components/truncate-title.ts +15 -0
- package/src/ui/use-frame-measure.ts +74 -0
- package/src/ui/use-terminal-virtualizer.ts +259 -0
- package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
- package/src/workbench/components/ChangesColumn.tsx +203 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
- package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.stories.tsx +86 -0
- package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.tsx +544 -0
- package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
- package/src/workbench/components/FileContentsView.stories.tsx +16 -0
- package/src/workbench/components/FileContentsView.test.tsx +29 -0
- package/src/workbench/components/FileContentsView.tsx +100 -0
- package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
- package/src/workbench/components/GridTabContent.stories.tsx +59 -0
- package/src/workbench/components/GridTabContent.tsx +193 -0
- package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
- package/src/workbench/components/ProjectTreeView.tsx +151 -0
- package/src/workbench/components/TreePane.stories.tsx +106 -0
- package/src/workbench/components/TreePane.tsx +155 -0
- package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
- package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
- package/src/workbench/components/WelcomePlayground.tsx +138 -0
- package/src/workbench/components/editor-grid.test.ts +184 -0
- package/src/workbench/components/editor-grid.ts +281 -0
- package/src/workbench/components/file-tree.test.ts +60 -0
- package/src/workbench/components/file-tree.ts +112 -0
- package/src/workbench/use-changes-workbench.ts +205 -0
- package/src/workbench/use-remember-layout.ts +31 -0
- package/src/workbench/use-repo-changes.ts +91 -0
- package/src/workbench/workbench-snapshot.test.ts +99 -0
- package/src/workbench/workbench-snapshot.ts +59 -0
- package/src/App.diff.test.tsx +0 -99
- package/src/App.export.test.tsx +0 -80
- package/src/App.readonly.test.tsx +0 -148
- package/src/App.test.tsx +0 -217
- package/src/App.tsx +0 -0
- package/src/config.test.ts +0 -116
- package/src/config.ts +0 -113
- package/src/editor.ts +0 -34
- package/src/run.ts +0 -28
- package/src/serve.ts +0 -75
- package/src/theme.ts +0 -46
- package/src/view-diff.test.ts +0 -55
- package/src/view-diff.ts +0 -86
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/app/App.tsx
ADDED
|
@@ -0,0 +1,1650 @@
|
|
|
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, KeyEvent } from "@opentui/core";
|
|
11
|
+
import { buildDisplay, marksByDisplay, type Mark } from "../markdown/view-plan";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_KEYS,
|
|
14
|
+
DEFAULT_QUICK_ACTIONS,
|
|
15
|
+
loadConfig,
|
|
16
|
+
persistAuthorName,
|
|
17
|
+
persistDiffView,
|
|
18
|
+
persistIdentity,
|
|
19
|
+
persistPins,
|
|
20
|
+
type AutoClose,
|
|
21
|
+
type DiffViewMode,
|
|
22
|
+
type IdentityConfig,
|
|
23
|
+
type QuickAction,
|
|
24
|
+
type ReviewWorkspaceMode,
|
|
25
|
+
} from "../settings/config";
|
|
26
|
+
import { resolveDisplayName } from "../integrations/attribution";
|
|
27
|
+
import { resolveGithubIdentity } from "../integrations/github-identity";
|
|
28
|
+
import { loadSkills, PaletteNamesContext, SlashSkillsContext } from "../keyboard/skills";
|
|
29
|
+
import { mergeSlashItems, slashItemsFrom, type SlashItem } from "../keyboard/slash-palette";
|
|
30
|
+
import {
|
|
31
|
+
composeTheme,
|
|
32
|
+
DEFAULT_THEME_NAME,
|
|
33
|
+
themeForName,
|
|
34
|
+
type Appearance,
|
|
35
|
+
type ThemeName,
|
|
36
|
+
} from "../appearance/theme-presets";
|
|
37
|
+
import type { Theme } from "../appearance/theme";
|
|
38
|
+
import {
|
|
39
|
+
createReviewController,
|
|
40
|
+
type ShareTransport,
|
|
41
|
+
type ReviewController,
|
|
42
|
+
} from "../thread/thread-controller";
|
|
43
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
44
|
+
import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
|
|
45
|
+
import { reduceKey, type KeyState } from "../keyboard/keymap";
|
|
46
|
+
import { KeyBindings, type CheatsheetSection } from "../keyboard/key-bindings";
|
|
47
|
+
import { useReadySignal } from "./ready-signal";
|
|
48
|
+
import { ThemeProvider } from "../appearance/components/theme-context";
|
|
49
|
+
import { Button } from "../ui/components/primitives/Button";
|
|
50
|
+
import { ShareDialog } from "../integrations/components/ShareDialog";
|
|
51
|
+
import { shareDialogStore } from "../integrations/components/share-dialog-store";
|
|
52
|
+
import { Toolbar } from "../ui/components/primitives/Toolbar";
|
|
53
|
+
import { groupInbox, projectName, threadTitle } from "../thread/components/thread-tree";
|
|
54
|
+
import { ThreadTree } from "../thread/components/ThreadTree";
|
|
55
|
+
import { ChangesFileTree } from "../workbench/components/ChangesColumn";
|
|
56
|
+
import { MenuControlProvider, useMenuControlState } from "../ui/components/menu-control";
|
|
57
|
+
import { ProjectTreeView } from "../workbench/components/ProjectTreeView";
|
|
58
|
+
import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
|
|
59
|
+
import type { ClientExtensionRegistry } from "../integrations/client-extension-registry";
|
|
60
|
+
import type { ExtensionUIContext } from "@cueloop/extension-api/client";
|
|
61
|
+
import { EditorGrid } from "../workbench/components/EditorGrid";
|
|
62
|
+
import { GridTabContent } from "../workbench/components/GridTabContent";
|
|
63
|
+
import { useChangesWorkbench } from "../workbench/use-changes-workbench";
|
|
64
|
+
import { useRepoChanges } from "../workbench/use-repo-changes";
|
|
65
|
+
import { useThreadBodyEditing } from "../markdown/use-thread-body-editing";
|
|
66
|
+
import { useRememberLayout } from "../workbench/use-remember-layout";
|
|
67
|
+
import type { LaunchLayout } from "./launch-layout";
|
|
68
|
+
import { ThreadFooter, THREAD_FOOTER_HEIGHT } from "../thread/components/ThreadFooter";
|
|
69
|
+
import { AgentThreadPrototype, agentOwnsKeyboard } from "../thread/components/AgentThreadPane";
|
|
70
|
+
import { ConfirmCard } from "../ui/components/ConfirmCard";
|
|
71
|
+
import { THREAD_VIEW_CHEATSHEET, ThreadView } from "../markdown/components/ThreadView";
|
|
72
|
+
import {
|
|
73
|
+
curationCommandEntries,
|
|
74
|
+
diffCommandEntries,
|
|
75
|
+
resolveNavKey,
|
|
76
|
+
resolveSessionChord,
|
|
77
|
+
sessionCommandEntries,
|
|
78
|
+
treeCommandEntries,
|
|
79
|
+
} from "../keyboard/thread-chords";
|
|
80
|
+
import { type DiffFoldControls } from "../diff/components/DiffContentView";
|
|
81
|
+
import { commentCountsByFile, readsFrozenDiff } from "../diff/view-diff";
|
|
82
|
+
import { annotationTarget, isAddressed, isAgentNote } from "@cueloop/schema";
|
|
83
|
+
import type {
|
|
84
|
+
Annotation,
|
|
85
|
+
Artifact,
|
|
86
|
+
DiffFileContents,
|
|
87
|
+
Identity,
|
|
88
|
+
ShareLink,
|
|
89
|
+
Thread,
|
|
90
|
+
MessageOutcome,
|
|
91
|
+
} from "@cueloop/schema";
|
|
92
|
+
import { PrototypePixels } from "../prototype/prototype-pixels";
|
|
93
|
+
import type { PrototypeElement } from "../prototype/prototype-browser";
|
|
94
|
+
import {
|
|
95
|
+
buildRenderFlags,
|
|
96
|
+
buildSubmitConfirmState,
|
|
97
|
+
computeRoleCapabilities,
|
|
98
|
+
deriveReviewFlags,
|
|
99
|
+
isPixelPrototypeMode,
|
|
100
|
+
isCompletionOverlayPhase,
|
|
101
|
+
isWalking,
|
|
102
|
+
resolveOverlay,
|
|
103
|
+
} from "./app-view-model";
|
|
104
|
+
import { buildKeyState } from "./app-key-state";
|
|
105
|
+
import { useSettingsDialog } from "../settings/use-settings-dialog";
|
|
106
|
+
import {
|
|
107
|
+
CompletionScreen,
|
|
108
|
+
ConnectingScreen,
|
|
109
|
+
ErrorScreen,
|
|
110
|
+
MenuChrome,
|
|
111
|
+
NoThreadShell,
|
|
112
|
+
TrailingOverlays,
|
|
113
|
+
} from "./app-screens";
|
|
114
|
+
/** A toast clears itself after this idle; esc dismisses it sooner. */
|
|
115
|
+
const TOAST_DISMISS_MS = 2000;
|
|
116
|
+
|
|
117
|
+
export interface AppProps {
|
|
118
|
+
home?: string;
|
|
119
|
+
sessionId?: string;
|
|
120
|
+
initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
|
|
121
|
+
initialClient?: ThreadClient;
|
|
122
|
+
cwd?: string;
|
|
123
|
+
readOnly?: boolean;
|
|
124
|
+
onExit?: (code: number) => void;
|
|
125
|
+
onReady?: () => void;
|
|
126
|
+
clock?: Clock;
|
|
127
|
+
openClient?: () => Promise<ThreadClient>;
|
|
128
|
+
shareTransport?: ShareTransport;
|
|
129
|
+
role?: "owner" | "observer" | "collaborator";
|
|
130
|
+
selfAuthor?: string;
|
|
131
|
+
appearance?: Appearance;
|
|
132
|
+
layout?: LaunchLayout;
|
|
133
|
+
servedArtifact?: Artifact;
|
|
134
|
+
extensionRegistry?: ClientExtensionRegistry;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function initialReviewControllerOptions(initialReview: AppProps["initialReview"]) {
|
|
138
|
+
const options = { initialSession: initialReview?.session, initialDiff: initialReview?.diff };
|
|
139
|
+
|
|
140
|
+
return options;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function initialReviewFiles(initialReview: AppProps["initialReview"], sessionId: string) {
|
|
144
|
+
const files = initialReview?.session.id === sessionId ? initialReview.diff.files : undefined;
|
|
145
|
+
|
|
146
|
+
return files;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function extensionContext(cwd: string | undefined, threadId: string | null): ExtensionUIContext {
|
|
150
|
+
return { workspace: cwd ?? process.cwd(), threadId };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** True while the drop-up or one of its dialogs is open and owns the keyboard. */
|
|
154
|
+
function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
|
|
155
|
+
return menuDialog !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether a thread already has at least one live share link. */
|
|
159
|
+
function isSharedThread(session: Thread | null): boolean {
|
|
160
|
+
return session !== null && (session.shares?.length ?? 0) > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A thread's live share links for the share dialog, or an empty list with no session. */
|
|
164
|
+
function shareLinksFor(session: Thread | null): ShareLink[] {
|
|
165
|
+
return session ? (session.shares ?? []) : [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The title the share dialog shows and prefills a new link with, or empty with no session. */
|
|
169
|
+
function shareThreadName(session: Thread | null): string {
|
|
170
|
+
return session ? threadTitle(session) : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Show the diff source before long titles can truncate it in the header. */
|
|
174
|
+
function reviewThreadTitle(session: Thread): string {
|
|
175
|
+
if (session.artifact.type === "diff" && session.artifact.meta.vcs)
|
|
176
|
+
return `vcs: ${session.artifact.meta.vcs} · ${threadTitle(session)}`;
|
|
177
|
+
|
|
178
|
+
return threadTitle(session);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
|
|
182
|
+
function keyboardHeldByMenu(
|
|
183
|
+
menuDialog: "keybinds" | "settings" | null,
|
|
184
|
+
openMenuId: string | null,
|
|
185
|
+
): boolean {
|
|
186
|
+
return menuChromeOpen(menuDialog) || openMenuId !== null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A non-modal toast: escape dismisses it only when nothing else owns escape (no overlay, not span mode). */
|
|
190
|
+
export function toastDismissRequested(
|
|
191
|
+
toastOpen: boolean,
|
|
192
|
+
key: { name: string },
|
|
193
|
+
overlay: KeyState["overlay"],
|
|
194
|
+
spanMode: boolean,
|
|
195
|
+
): boolean {
|
|
196
|
+
return toastOpen && key.name === "escape" && overlay === "none" && !spanMode;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** ctrl+q quits from anywhere, ahead of every focus/menu/overlay gate, with no confirmation. */
|
|
200
|
+
export function quitKeyHandled(
|
|
201
|
+
key: { name: string; ctrl?: boolean },
|
|
202
|
+
onExit?: (code: number) => void,
|
|
203
|
+
): boolean {
|
|
204
|
+
if (!key.ctrl || key.name !== "q") return false;
|
|
205
|
+
onExit?.(0);
|
|
206
|
+
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A popover menu is open, so the shell keyboard is modal: escape closes it, every other key is swallowed. */
|
|
211
|
+
function menuModalHandled(
|
|
212
|
+
menuControl: { openMenuId: string | null; closeMenu: () => void },
|
|
213
|
+
key: { name: string },
|
|
214
|
+
): boolean {
|
|
215
|
+
if (menuControl.openMenuId === null) return false;
|
|
216
|
+
if (key.name === "escape") menuControl.closeMenu();
|
|
217
|
+
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** True while a menu, an overlay, or the share dialog owns the keyboard instead of the thread view. */
|
|
222
|
+
function keyboardOwnedElsewhere(
|
|
223
|
+
menuOwnsKeyboard: boolean,
|
|
224
|
+
overlay: KeyState["overlay"],
|
|
225
|
+
shareDialogOpen: boolean,
|
|
226
|
+
): boolean {
|
|
227
|
+
return menuOwnsKeyboard || overlay !== "none" || shareDialogOpen;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The footer submit fires only for the owner of an unresolved review, never an observer. */
|
|
231
|
+
function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
|
|
232
|
+
return isOwner && !resolved && !observer;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The thread body edits inline only for its owner, on an unresolved markdown thread (never a diff). */
|
|
236
|
+
function canEditThreadBody(
|
|
237
|
+
threadViewActive: boolean,
|
|
238
|
+
isDiff: boolean,
|
|
239
|
+
isOwner: boolean,
|
|
240
|
+
resolved: boolean,
|
|
241
|
+
): boolean {
|
|
242
|
+
return threadViewActive && !isDiff && isOwner && !resolved;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The owner's header actions: an edit/normal toggle that opens or leaves the inline editor, plus share when not editing. */
|
|
246
|
+
function ownerThreadActions(actions: {
|
|
247
|
+
editing: boolean;
|
|
248
|
+
onEdit: () => void;
|
|
249
|
+
onExitEdit: () => void;
|
|
250
|
+
onShare: () => void;
|
|
251
|
+
theme: Theme;
|
|
252
|
+
}): React.ReactNode {
|
|
253
|
+
return (
|
|
254
|
+
<Toolbar>
|
|
255
|
+
<Button onPress={actions.editing ? actions.onExitEdit : actions.onEdit} theme={actions.theme}>
|
|
256
|
+
{actions.editing ? " normal " : " edit "}
|
|
257
|
+
</Button>
|
|
258
|
+
{actions.editing ? null : (
|
|
259
|
+
<Button onPress={actions.onShare} theme={actions.theme}>
|
|
260
|
+
{" share "}
|
|
261
|
+
</Button>
|
|
262
|
+
)}
|
|
263
|
+
</Toolbar>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function refreshDiffAction(onRefresh: () => void, theme: Theme): React.ReactNode {
|
|
268
|
+
return (
|
|
269
|
+
<Toolbar>
|
|
270
|
+
<Button onPress={onRefresh} foreground={theme.warning} theme={theme}>
|
|
271
|
+
{" refresh "}
|
|
272
|
+
</Button>
|
|
273
|
+
</Toolbar>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Present pull request context with the ordinary read-only Thread renderer. */
|
|
278
|
+
function pullRequestBriefThread(session: Thread): Thread {
|
|
279
|
+
return {
|
|
280
|
+
...session,
|
|
281
|
+
artifact: {
|
|
282
|
+
...session.artifact,
|
|
283
|
+
type: "reply",
|
|
284
|
+
content: session.artifact.meta.prBrief ?? "",
|
|
285
|
+
},
|
|
286
|
+
workingCopy: undefined,
|
|
287
|
+
annotations: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Pick the thread pane's body: the pixel prototype, the diff placeholder, the inline editor, or the read-only view. */
|
|
292
|
+
function chooseThreadBody(choice: {
|
|
293
|
+
isPixelPrototype: boolean;
|
|
294
|
+
isDiff: boolean;
|
|
295
|
+
editingBody: boolean;
|
|
296
|
+
prototype: React.ReactNode;
|
|
297
|
+
diffPlaceholder: React.ReactNode;
|
|
298
|
+
editor: React.ReactNode;
|
|
299
|
+
threadView: React.ReactNode;
|
|
300
|
+
}): React.ReactNode {
|
|
301
|
+
if (choice.isPixelPrototype) return choice.prototype;
|
|
302
|
+
if (choice.isDiff) return choice.diffPlaceholder;
|
|
303
|
+
if (choice.editingBody) return choice.editor;
|
|
304
|
+
|
|
305
|
+
return choice.threadView;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The author display name for a comment's hover tooltip: the reviewer's own name for their notes, else the resolved collaborator name. */
|
|
309
|
+
function authorLabelResolver(
|
|
310
|
+
ownName: string | undefined,
|
|
311
|
+
participants: Identity[] | undefined,
|
|
312
|
+
overrides: Record<string, string>,
|
|
313
|
+
): (annotation: Annotation) => string | undefined {
|
|
314
|
+
return (annotation) =>
|
|
315
|
+
annotation.author === undefined
|
|
316
|
+
? (ownName ?? "you")
|
|
317
|
+
: resolveDisplayName(annotation.author, participants, overrides);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
|
|
321
|
+
function ProjectPanelBody(props: {
|
|
322
|
+
mode: ProjectPanelMode;
|
|
323
|
+
loadChanges: () => Promise<readonly DiffFileContents[]>;
|
|
324
|
+
initialFiles?: readonly DiffFileContents[];
|
|
325
|
+
loadProjectFiles: () => Promise<string[]>;
|
|
326
|
+
diffSourceKey: string;
|
|
327
|
+
liveWorkingTree: boolean;
|
|
328
|
+
frozenFiles?: readonly DiffFileContents[];
|
|
329
|
+
onOpenChangedFile: (path: string, persistent?: boolean) => void;
|
|
330
|
+
onOpenProjectFile: (path: string, persistent?: boolean) => void;
|
|
331
|
+
commentCounts?: ReadonlyMap<string, number>;
|
|
332
|
+
focused?: boolean;
|
|
333
|
+
theme: Theme;
|
|
334
|
+
}): React.ReactNode {
|
|
335
|
+
const changes = useRepoChanges({
|
|
336
|
+
loadChanges: props.loadChanges,
|
|
337
|
+
visible: props.mode === "changes" && props.liveWorkingTree,
|
|
338
|
+
diffSourceKey: props.diffSourceKey,
|
|
339
|
+
initialFiles: props.initialFiles,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (props.mode === "changes") {
|
|
343
|
+
return (
|
|
344
|
+
<ChangesFileTree
|
|
345
|
+
files={props.liveWorkingTree ? changes : (props.frozenFiles ?? [])}
|
|
346
|
+
onSelectFile={props.onOpenChangedFile}
|
|
347
|
+
commentCounts={props.commentCounts}
|
|
348
|
+
focused={props.focused}
|
|
349
|
+
theme={props.theme}
|
|
350
|
+
/>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<ProjectTreeView
|
|
356
|
+
key={props.diffSourceKey}
|
|
357
|
+
loadFiles={props.loadProjectFiles}
|
|
358
|
+
onSelectFile={props.onOpenProjectFile}
|
|
359
|
+
focused={props.focused}
|
|
360
|
+
theme={props.theme}
|
|
361
|
+
/>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Frozen reviews read their captured files directly; live workspaces poll the repo. */
|
|
366
|
+
interface ProjectDiffFiles {
|
|
367
|
+
liveWorkingTree: boolean;
|
|
368
|
+
frozenFiles?: readonly DiffFileContents[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function projectDiffFiles(session: Thread): ProjectDiffFiles {
|
|
372
|
+
const frozen = readsFrozenDiff(session);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
liveWorkingTree: !frozen,
|
|
376
|
+
frozenFiles: frozen ? session.artifact.files : undefined,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
|
|
381
|
+
function usableScreenReached(
|
|
382
|
+
error: string | null,
|
|
383
|
+
session: Thread | null,
|
|
384
|
+
inbox: Thread[] | null,
|
|
385
|
+
): boolean {
|
|
386
|
+
return Boolean(error) || session !== null || inbox !== null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
|
|
390
|
+
function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
|
|
391
|
+
const base = keyBindings.cheatsheet();
|
|
392
|
+
const fileTree: CheatsheetSection = {
|
|
393
|
+
title: "Changes / Project files",
|
|
394
|
+
entries: [
|
|
395
|
+
{ keys: "enter", label: "open selected file or toggle folder" },
|
|
396
|
+
{ keys: "tab", label: "cycle panes" },
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (!threadViewActive) {
|
|
401
|
+
return [fileTree, ...base];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return [
|
|
405
|
+
fileTree,
|
|
406
|
+
...THREAD_VIEW_CHEATSHEET,
|
|
407
|
+
{ title: "Nav mode · session", entries: sessionCommandEntries() },
|
|
408
|
+
{ title: "Nav mode · diff", entries: diffCommandEntries() },
|
|
409
|
+
{ title: "Nav mode · discussion", entries: curationCommandEntries() },
|
|
410
|
+
{ title: "Nav mode · history", entries: treeCommandEntries() },
|
|
411
|
+
];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The Threads sidebar opens from the launch layout; a bare launch opens it only on the inbox. */
|
|
415
|
+
function initialThreadsOpen(
|
|
416
|
+
layout: LaunchLayout | undefined,
|
|
417
|
+
sessionId: string | undefined,
|
|
418
|
+
): boolean {
|
|
419
|
+
return layout ? layout.threads : sessionId === undefined;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function initialFocusedPane(
|
|
423
|
+
sessionId: string | undefined,
|
|
424
|
+
zoomed: boolean,
|
|
425
|
+
layout?: LaunchLayout,
|
|
426
|
+
): FocusPane {
|
|
427
|
+
if (sessionId === undefined) return "threads";
|
|
428
|
+
|
|
429
|
+
return layout?.focusPane ?? (zoomed ? "changes" : "thread");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function bareShellDefersKeys(
|
|
433
|
+
sessionIsNull: boolean,
|
|
434
|
+
overlayIsNone: boolean,
|
|
435
|
+
focusedPane: FocusPane,
|
|
436
|
+
): boolean {
|
|
437
|
+
return sessionIsNull && overlayIsNone && focusedPane !== "threads";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function surfaceSuspended(base: boolean, focusedPane: FocusPane, pane: FocusPane): boolean {
|
|
441
|
+
return base || focusedPane !== pane;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function threadsNavHandled(params: {
|
|
445
|
+
focusedPane: FocusPane;
|
|
446
|
+
quiet: boolean;
|
|
447
|
+
key: { name: string };
|
|
448
|
+
count: number;
|
|
449
|
+
setInboxCursor: (update: (cursor: number) => number) => void;
|
|
450
|
+
openSession: () => void;
|
|
451
|
+
}): boolean {
|
|
452
|
+
const { focusedPane, quiet, key, count } = params;
|
|
453
|
+
|
|
454
|
+
if (focusedPane !== "threads" || !quiet || count === 0) return false;
|
|
455
|
+
if (key.name === "j" || key.name === "down") {
|
|
456
|
+
params.setInboxCursor((cursor) => Math.min(cursor + 1, count - 1));
|
|
457
|
+
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
if (key.name === "k" || key.name === "up") {
|
|
461
|
+
params.setInboxCursor((cursor) => Math.max(cursor - 1, 0));
|
|
462
|
+
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
if (key.name === "return" || key.name === "enter") {
|
|
466
|
+
params.openSession();
|
|
467
|
+
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function paneCycleRequested(
|
|
475
|
+
key: { name: string },
|
|
476
|
+
overlay: string,
|
|
477
|
+
menuOwnsKeyboard: boolean,
|
|
478
|
+
threadComposing: boolean,
|
|
479
|
+
): boolean {
|
|
480
|
+
return key.name === "tab" && overlay === "none" && !menuOwnsKeyboard && !threadComposing;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function visiblePanes(
|
|
484
|
+
sidebarOpen: boolean,
|
|
485
|
+
threadShown: boolean,
|
|
486
|
+
changesOpen: boolean,
|
|
487
|
+
projectOpen: boolean,
|
|
488
|
+
): FocusPane[] {
|
|
489
|
+
const panes: FocusPane[] = [];
|
|
490
|
+
|
|
491
|
+
if (sidebarOpen) panes.push("threads");
|
|
492
|
+
if (threadShown) panes.push("thread");
|
|
493
|
+
if (changesOpen) panes.push("changes");
|
|
494
|
+
if (projectOpen) panes.push("project");
|
|
495
|
+
|
|
496
|
+
return panes;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function nextFocusPane(
|
|
500
|
+
current: FocusPane,
|
|
501
|
+
panes: FocusPane[],
|
|
502
|
+
backward: boolean,
|
|
503
|
+
): FocusPane {
|
|
504
|
+
if (panes.length === 0) return current;
|
|
505
|
+
const index = panes.indexOf(current);
|
|
506
|
+
const step = backward ? -1 : 1;
|
|
507
|
+
|
|
508
|
+
return panes[(index + step + panes.length) % panes.length] ?? current;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The panes that can own the keyboard: a diff's Thread pane is an inert placeholder, so it is out. */
|
|
512
|
+
function navigableFocusPanes(
|
|
513
|
+
sidebarOpen: boolean,
|
|
514
|
+
zoomed: boolean,
|
|
515
|
+
isDiff: boolean,
|
|
516
|
+
changesOpen: boolean,
|
|
517
|
+
projectOpen: boolean,
|
|
518
|
+
): FocusPane[] {
|
|
519
|
+
return visiblePanes(sidebarOpen, !zoomed && !isDiff, changesOpen, projectOpen);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The layout hid or inert-ed the focused pane: fall back to the first navigable one, else stay put. */
|
|
523
|
+
export function reconciledFocus(navigable: FocusPane[], focusedPane: FocusPane): FocusPane | null {
|
|
524
|
+
if (navigable.length === 0 || navigable.includes(focusedPane)) return null;
|
|
525
|
+
|
|
526
|
+
return navigable[0] ?? null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function App({
|
|
530
|
+
home,
|
|
531
|
+
sessionId,
|
|
532
|
+
initialReview,
|
|
533
|
+
initialClient,
|
|
534
|
+
cwd,
|
|
535
|
+
readOnly = false,
|
|
536
|
+
onExit,
|
|
537
|
+
onReady,
|
|
538
|
+
clock,
|
|
539
|
+
openClient,
|
|
540
|
+
shareTransport,
|
|
541
|
+
role = "owner",
|
|
542
|
+
selfAuthor,
|
|
543
|
+
appearance = "dark",
|
|
544
|
+
layout,
|
|
545
|
+
servedArtifact,
|
|
546
|
+
extensionRegistry,
|
|
547
|
+
}: AppProps): React.ReactNode {
|
|
548
|
+
const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
|
|
549
|
+
const controller = useMemo(
|
|
550
|
+
() =>
|
|
551
|
+
createReviewController({
|
|
552
|
+
home,
|
|
553
|
+
sessionId,
|
|
554
|
+
...initialReviewControllerOptions(initialReview),
|
|
555
|
+
initialClient,
|
|
556
|
+
cwd,
|
|
557
|
+
readOnly: observer,
|
|
558
|
+
onExit,
|
|
559
|
+
clock,
|
|
560
|
+
openClient,
|
|
561
|
+
shareTransport,
|
|
562
|
+
servedArtifact,
|
|
563
|
+
}),
|
|
564
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
565
|
+
[home, sessionId],
|
|
566
|
+
);
|
|
567
|
+
const showExtensionError = useCallback(
|
|
568
|
+
(message: string) => controller.showToast(message),
|
|
569
|
+
[controller],
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
useEffect(() => {
|
|
573
|
+
controller.connect();
|
|
574
|
+
|
|
575
|
+
return () => controller.close();
|
|
576
|
+
}, [controller]);
|
|
577
|
+
// stable across renders so the memoized PrototypeContentView is not
|
|
578
|
+
// re-rendered by unrelated App state (status ticks, a rail-width drag)
|
|
579
|
+
const onCommentPrototype = useCallback(
|
|
580
|
+
(element: PrototypeElement, body: string) =>
|
|
581
|
+
controller.annotatePrototype(element.selector, element.quote, body),
|
|
582
|
+
[controller],
|
|
583
|
+
);
|
|
584
|
+
const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
|
|
585
|
+
useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
586
|
+
const initialReviewForFirstMount = useRef(initialReview);
|
|
587
|
+
|
|
588
|
+
useEffect(() => {
|
|
589
|
+
initialReviewForFirstMount.current = undefined;
|
|
590
|
+
}, []);
|
|
591
|
+
|
|
592
|
+
// A shared plan you own polls for collaborator notes while it is open; the
|
|
593
|
+
// merge refreshes through the normal event path. Stops on leave.
|
|
594
|
+
const threadShared = isSharedThread(session);
|
|
595
|
+
|
|
596
|
+
useEffect(() => {
|
|
597
|
+
if (!isOwner || !threadShared) return;
|
|
598
|
+
|
|
599
|
+
return controller.startShareSync();
|
|
600
|
+
}, [isOwner, session?.id, threadShared, controller]);
|
|
601
|
+
const renderer = useRenderer();
|
|
602
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
603
|
+
|
|
604
|
+
// ── view state ──────────────────────────────
|
|
605
|
+
const [cursor, setCursor] = useState(0);
|
|
606
|
+
const [inboxCursor, setInboxCursor] = useState(0);
|
|
607
|
+
// Projects and Threads grouping; ordered is the flat sequence the inbox cursor walks
|
|
608
|
+
// pinned threads are client-local view state, seeded from the user config and
|
|
609
|
+
// persisted on toggle; a pin lifts the thread into the sidebar's Pinned section
|
|
610
|
+
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set());
|
|
611
|
+
const grouped = useMemo(() => groupInbox(inbox ?? [], pinnedIds), [inbox, pinnedIds]);
|
|
612
|
+
const togglePin = (id: string): void =>
|
|
613
|
+
setPinnedIds((current) => {
|
|
614
|
+
const next = new Set(current);
|
|
615
|
+
|
|
616
|
+
if (next.has(id)) next.delete(id);
|
|
617
|
+
else next.add(id);
|
|
618
|
+
persistPins([...next]);
|
|
619
|
+
|
|
620
|
+
return next;
|
|
621
|
+
});
|
|
622
|
+
// the left Projects and Threads column; collapsed by default per the context
|
|
623
|
+
// matrix (the thread owns the width), toggled open to jump between threads
|
|
624
|
+
// one sidebar-open state across the no-thread shell and the thread view, so
|
|
625
|
+
// picking a thread keeps the sidebar as it was. It opens when the app lands with
|
|
626
|
+
// nothing selected (pick a thread) and stays collapsed on a direct thread open,
|
|
627
|
+
// where the thread owns the width.
|
|
628
|
+
const [sidebarOpen, setSidebarOpen] = useState(() => initialThreadsOpen(layout, sessionId));
|
|
629
|
+
// the Changes + Project right region and its editor grid (tabs, splits, zoom)
|
|
630
|
+
const workbench = useChangesWorkbench({ layout });
|
|
631
|
+
const [focusedPane, setFocusedPane] = useState<FocusPane>(() =>
|
|
632
|
+
initialFocusedPane(sessionId, workbench.zoomed, layout),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
// only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
|
|
636
|
+
// workbench stays inactive with no session and never clobbers what the shell saved
|
|
637
|
+
useRememberLayout(
|
|
638
|
+
layout,
|
|
639
|
+
session !== null,
|
|
640
|
+
sidebarOpen,
|
|
641
|
+
workbench.changesOpen,
|
|
642
|
+
workbench.projectOpen,
|
|
643
|
+
workbench.zoomed,
|
|
644
|
+
);
|
|
645
|
+
const [mode, setMode] = useState<Mode>({ type: "normal" });
|
|
646
|
+
// the top-left settings gear drop-down and the centered dialog it opens
|
|
647
|
+
const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
|
|
648
|
+
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
|
649
|
+
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
650
|
+
// unified or side-by-side diff; split lays out when the Changes pane is zoomed
|
|
651
|
+
const [diffView, setDiffView] = useState<DiffViewMode>("split");
|
|
652
|
+
const [defaultMessage, setDefaultMessage] = useState<MessageOutcome>("approved");
|
|
653
|
+
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
654
|
+
const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
|
|
655
|
+
const [railTab, setRailTab] = useState<RailTab>("review");
|
|
656
|
+
// the tree row the reviewer stands on in the session tree
|
|
657
|
+
const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
|
|
658
|
+
// ~2s focus pulse on the document highlight when a card is activated
|
|
659
|
+
const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
|
|
660
|
+
const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
661
|
+
// live mirror of overlay input text: refs commit synchronously, so the
|
|
662
|
+
// RETURN handler never reads a stale value mid-typing
|
|
663
|
+
const liveInput = useRef("");
|
|
664
|
+
// keymap from layered config; the loaded theme swaps the provider value
|
|
665
|
+
const keysRef = useRef(DEFAULT_KEYS);
|
|
666
|
+
const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
|
|
667
|
+
const [theme, setTheme] = useState(() => themeForName(DEFAULT_THEME_NAME, appearance));
|
|
668
|
+
const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
|
|
669
|
+
const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
|
|
670
|
+
const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
|
|
671
|
+
const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
|
|
672
|
+
const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
|
|
673
|
+
const [skills, setSkills] = useState<SlashItem[]>([]);
|
|
674
|
+
const [reviewSkill, setReviewSkill] = useState("code-review");
|
|
675
|
+
const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
|
|
676
|
+
const paletteNames = useMemo(
|
|
677
|
+
() => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
|
|
678
|
+
[quickActions, skills],
|
|
679
|
+
);
|
|
680
|
+
// opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
|
|
681
|
+
const [prototypePixels, setPrototypePixels] = useState(false);
|
|
682
|
+
const [threadAgentEnabled, setThreadAgentEnabled] = useState(false);
|
|
683
|
+
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
686
|
+
|
|
687
|
+
setPrototypePixels(config.experimental.prototypePixels);
|
|
688
|
+
setThreadAgentEnabled(config.experimental.threadAgent);
|
|
689
|
+
keysRef.current = config.keys;
|
|
690
|
+
keyBindings.setKeys(config.keys);
|
|
691
|
+
setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
|
|
692
|
+
setThemeName(config.ui.theme);
|
|
693
|
+
setThemeOverrides(config.themeOverrides);
|
|
694
|
+
setAuthorNames(config.authors);
|
|
695
|
+
setIdentity(config.identity);
|
|
696
|
+
setQuickActions(config.actions);
|
|
697
|
+
setSkills(loadSkills(config.skillsPath));
|
|
698
|
+
setReviewSkill(config.review.skill);
|
|
699
|
+
setReviewWorkspace(config.review.workspace);
|
|
700
|
+
setAutoClose(config.ui.autoClose);
|
|
701
|
+
setDiffView(config.ui.diffView);
|
|
702
|
+
setDefaultMessage(config.ui.defaultMessage);
|
|
703
|
+
setPinnedIds(new Set(config.ui.pins));
|
|
704
|
+
controller.applyConfig(config);
|
|
705
|
+
}, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
|
|
706
|
+
useEffect(
|
|
707
|
+
() => () => {
|
|
708
|
+
if (pulseTimer.current) clearTimeout(pulseTimer.current);
|
|
709
|
+
},
|
|
710
|
+
[],
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
useEffect(() => {
|
|
714
|
+
if (!toast) return;
|
|
715
|
+
const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
|
|
716
|
+
|
|
717
|
+
return () => clearTimeout(timer);
|
|
718
|
+
}, [toast, controller]);
|
|
719
|
+
|
|
720
|
+
// First open of a share: ask the collaborator for a display name once, unless
|
|
721
|
+
// a past visit already recorded one. esc skips and their notes read anonymous.
|
|
722
|
+
const promptedSelfRef = useRef(false);
|
|
723
|
+
|
|
724
|
+
useEffect(() => {
|
|
725
|
+
if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
|
|
726
|
+
promptedSelfRef.current = true;
|
|
727
|
+
const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
|
|
728
|
+
|
|
729
|
+
if (!known) setMode({ type: "nameSelf", text: "" });
|
|
730
|
+
}, [role, selfAuthor, session]);
|
|
731
|
+
|
|
732
|
+
// Bumped on every identity change so a slow GitHub sync that lands after a newer rename is discarded.
|
|
733
|
+
const identityGenerationRef = useRef(0);
|
|
734
|
+
const applyIdentity = (next: IdentityConfig): void => {
|
|
735
|
+
identityGenerationRef.current += 1;
|
|
736
|
+
setIdentity(next);
|
|
737
|
+
try {
|
|
738
|
+
persistIdentity(next);
|
|
739
|
+
} catch {
|
|
740
|
+
controller.setStatus("could not save identity to config");
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
const syncGithubIdentity = (): void => {
|
|
744
|
+
const generation = (identityGenerationRef.current += 1);
|
|
745
|
+
|
|
746
|
+
void resolveGithubIdentity().then((github) => {
|
|
747
|
+
if (identityGenerationRef.current !== generation) return;
|
|
748
|
+
if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
|
|
749
|
+
const name = github.name?.trim() || github.login;
|
|
750
|
+
|
|
751
|
+
applyIdentity({ name, provider: "github" });
|
|
752
|
+
});
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// ── settings dialog: config-backed model, navigation, persistence ──
|
|
756
|
+
const {
|
|
757
|
+
settingsNav,
|
|
758
|
+
settingsCategories,
|
|
759
|
+
settingsValues,
|
|
760
|
+
cycleSetting,
|
|
761
|
+
handleSettingsKey,
|
|
762
|
+
onCategorySelect,
|
|
763
|
+
openSettings,
|
|
764
|
+
} = useSettingsDialog({
|
|
765
|
+
theme,
|
|
766
|
+
appearance,
|
|
767
|
+
autoClose,
|
|
768
|
+
setAutoClose,
|
|
769
|
+
diffView,
|
|
770
|
+
setDiffView,
|
|
771
|
+
themeName,
|
|
772
|
+
setThemeName,
|
|
773
|
+
themeOverrides,
|
|
774
|
+
setTheme,
|
|
775
|
+
quickActions,
|
|
776
|
+
setQuickActions,
|
|
777
|
+
setMenuDialog,
|
|
778
|
+
identityName: identity.name,
|
|
779
|
+
identityProvider: identity.provider,
|
|
780
|
+
onSyncGithubIdentity: syncGithubIdentity,
|
|
781
|
+
onRenameDisplayName: () => {
|
|
782
|
+
setMenuDialog(null);
|
|
783
|
+
setMode({ type: "renameSelf", text: identity.name ?? "" });
|
|
784
|
+
},
|
|
785
|
+
reviewSkill,
|
|
786
|
+
reviewWorkspace,
|
|
787
|
+
setReviewWorkspace,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ── derived view model ──────────────────────
|
|
791
|
+
const display = controller.display();
|
|
792
|
+
const rows = controller.rows();
|
|
793
|
+
const rejectedRows = controller.rejectedRows();
|
|
794
|
+
const marks = useMemo(
|
|
795
|
+
() =>
|
|
796
|
+
session
|
|
797
|
+
? marksByDisplay(
|
|
798
|
+
// the plan thread shows only artifact notes; Changes-diff notes render on that surface
|
|
799
|
+
session.annotations.filter(
|
|
800
|
+
(annotation) => annotationTarget(annotation).kind === "artifact",
|
|
801
|
+
),
|
|
802
|
+
display,
|
|
803
|
+
pulsedAnnotationId ?? undefined,
|
|
804
|
+
)
|
|
805
|
+
: new Map<number, Mark[]>(),
|
|
806
|
+
[session, display, pulsedAnnotationId],
|
|
807
|
+
);
|
|
808
|
+
/** Annotation ids whose anchor resolved against the working copy. */
|
|
809
|
+
const resolvedIds = useMemo(() => {
|
|
810
|
+
const ids = new Set<string>();
|
|
811
|
+
|
|
812
|
+
for (const blockMarks of marks.values()) {
|
|
813
|
+
for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return ids;
|
|
817
|
+
}, [marks]);
|
|
818
|
+
const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
|
|
819
|
+
// a prototype is a markdown design doc by default; only the opt-in experimental
|
|
820
|
+
// pixel mode (flag on + an HTML entry) renders it as a kitty mockup instead
|
|
821
|
+
const isPixelPrototype = isPixelPrototypeMode(
|
|
822
|
+
isPrototype,
|
|
823
|
+
prototypePixels,
|
|
824
|
+
session?.artifact.meta.prototypePath,
|
|
825
|
+
);
|
|
826
|
+
// comments per changed-file path, for the changed-files tree and tab badges
|
|
827
|
+
const diffCommentCounts = useMemo(
|
|
828
|
+
() => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
|
|
829
|
+
[session, isDiff, rows],
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
// entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
|
|
833
|
+
workbench.syncSession(session?.id, isDiff);
|
|
834
|
+
// plans, replies, and the default (markdown) prototype open in the thread view, diffs in the
|
|
835
|
+
// diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
|
|
836
|
+
const threadViewActive = session !== null && !isPixelPrototype;
|
|
837
|
+
const [threadComposing, setThreadComposing] = useState(false);
|
|
838
|
+
const [agentActive, setAgentActive] = useState(false);
|
|
839
|
+
const [agentControls, setAgentControls] = useState<React.ReactNode>(null);
|
|
840
|
+
const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
|
|
841
|
+
() => undefined,
|
|
842
|
+
);
|
|
843
|
+
const agentInvoke = useRef<(() => void) | undefined>(undefined);
|
|
844
|
+
const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
|
|
845
|
+
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
846
|
+
const [welcomeComposing, setWelcomeComposing] = useState(false);
|
|
847
|
+
// inline body edit: the markdown editor owns the thread pane and all keys while open
|
|
848
|
+
const canEditBody = canEditThreadBody(threadViewActive, isDiff, isOwner, resolved);
|
|
849
|
+
const bodyEditing = useThreadBodyEditing({
|
|
850
|
+
controller,
|
|
851
|
+
sessionId: session?.id,
|
|
852
|
+
canEdit: canEditBody,
|
|
853
|
+
});
|
|
854
|
+
// sort position per annotation so the rail interleaves annotation and removal
|
|
855
|
+
// cards in one line-ordered stack: a diff row carries its blockIndex; a plan
|
|
856
|
+
// annotation resolves to the display index it marked
|
|
857
|
+
// ── the guided walk's view model ────────────
|
|
858
|
+
const walkFileList = controller.files();
|
|
859
|
+
const walking = isWalking(isDiff, walk);
|
|
860
|
+
const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
|
|
861
|
+
|
|
862
|
+
// ── selection symmetry: one selected id, both sides ──
|
|
863
|
+
const selectCardFromDocument = (annotationId: string): void => {
|
|
864
|
+
setFocusedAnnotationId(annotationId);
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const openCardEdit = (annotationId: string): void => {
|
|
868
|
+
if (observer) return controller.setStatus("observer - read-only");
|
|
869
|
+
const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
|
|
870
|
+
|
|
871
|
+
if (!annotation) return;
|
|
872
|
+
// a collaborator's note is theirs to word: activating it (click or e) renames
|
|
873
|
+
// the author rather than editing the body the planner does not own
|
|
874
|
+
if (annotation.author) {
|
|
875
|
+
setFocusedAnnotationId(annotationId);
|
|
876
|
+
|
|
877
|
+
return void setMode({
|
|
878
|
+
type: "rename",
|
|
879
|
+
authorId: annotation.author,
|
|
880
|
+
text: authorNames[annotation.author] ?? "",
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
884
|
+
liveInput.current = annotation.body;
|
|
885
|
+
setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
// a markdown thread edits inline; a diff is not markdown, so it keeps the external-editor hand-off
|
|
889
|
+
const openBodyEditor = (): void => {
|
|
890
|
+
if (canEditBody) {
|
|
891
|
+
bodyEditing.openEditor();
|
|
892
|
+
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
renderer?.suspend();
|
|
896
|
+
try {
|
|
897
|
+
controller.edit();
|
|
898
|
+
} finally {
|
|
899
|
+
renderer?.resume();
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
// ── keyboard grammar: build state, reduce, dispatch ──
|
|
904
|
+
const dispatch = createIntentDispatch({
|
|
905
|
+
controller,
|
|
906
|
+
onExit,
|
|
907
|
+
isDiff,
|
|
908
|
+
display,
|
|
909
|
+
rows,
|
|
910
|
+
cursor,
|
|
911
|
+
inbox: inbox === null ? null : grouped.ordered,
|
|
912
|
+
inboxCursor,
|
|
913
|
+
mode,
|
|
914
|
+
session,
|
|
915
|
+
defaultMessage,
|
|
916
|
+
focusedAnnotationId,
|
|
917
|
+
selectedCurationId,
|
|
918
|
+
railTab,
|
|
919
|
+
selectedEntryId,
|
|
920
|
+
authorNames,
|
|
921
|
+
quickActions,
|
|
922
|
+
renameAuthor: (id: string, name: string) => {
|
|
923
|
+
persistAuthorName(id, name);
|
|
924
|
+
setAuthorNames((prev) => ({ ...prev, [id]: name }));
|
|
925
|
+
},
|
|
926
|
+
renameThread: (id: string, title: string) => controller.renameSession(id, title),
|
|
927
|
+
setLocalIdentityName: (name: string) => {
|
|
928
|
+
applyIdentity(name ? { name, provider: "typed" } : { provider: "typed" });
|
|
929
|
+
},
|
|
930
|
+
liveInput,
|
|
931
|
+
setCursor,
|
|
932
|
+
setInboxCursor,
|
|
933
|
+
setMode,
|
|
934
|
+
setRailTab,
|
|
935
|
+
setSelectedEntryId,
|
|
936
|
+
setFocusedAnnotationId,
|
|
937
|
+
setSelectedCurationId,
|
|
938
|
+
setPulsedAnnotationId,
|
|
939
|
+
selectCardFromDocument,
|
|
940
|
+
openBodyEditor,
|
|
941
|
+
openCardEdit,
|
|
942
|
+
toggleDiffView: () => {
|
|
943
|
+
const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
|
|
944
|
+
|
|
945
|
+
setDiffView(next);
|
|
946
|
+
persistDiffView(next);
|
|
947
|
+
},
|
|
948
|
+
openShareDialog: () => {
|
|
949
|
+
if (!isOwner) return controller.setStatus("only the plan owner can share");
|
|
950
|
+
shareDialogStore.getState().reset();
|
|
951
|
+
setShareDialogOpen(true);
|
|
952
|
+
},
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const overlay = resolveOverlay(mode, completion.phase, walking);
|
|
956
|
+
|
|
957
|
+
const menuControl = useMenuControlState();
|
|
958
|
+
const menuOwnsKeyboard = keyboardHeldByMenu(menuDialog, menuControl.openMenuId);
|
|
959
|
+
// an overlay (submit, walk, prompt, confirm, share choice), the manage-access
|
|
960
|
+
// dialog, or the menu takes the keyboard from the thread view; the view suspends
|
|
961
|
+
// its own grammar meanwhile so typing lands in the dialog, not the thread
|
|
962
|
+
const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, shareDialogOpen);
|
|
963
|
+
|
|
964
|
+
const navigablePanes = navigableFocusPanes(
|
|
965
|
+
sidebarOpen,
|
|
966
|
+
workbench.zoomed,
|
|
967
|
+
isDiff,
|
|
968
|
+
workbench.changesOpen,
|
|
969
|
+
workbench.projectOpen,
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
useEffect(() => {
|
|
973
|
+
// the bare shell drives its panes from its own workbench, so only reconcile against this one
|
|
974
|
+
if (!session) return;
|
|
975
|
+
const next = reconciledFocus(navigablePanes, focusedPane);
|
|
976
|
+
|
|
977
|
+
if (next) setFocusedPane(next);
|
|
978
|
+
}, [session, navigablePanes, focusedPane]);
|
|
979
|
+
const cyclePanes = (backward: boolean): void =>
|
|
980
|
+
setFocusedPane((current) => nextFocusPane(current, navigablePanes, backward));
|
|
981
|
+
const runNavCommand = (
|
|
982
|
+
key: { name: string; shift?: boolean },
|
|
983
|
+
selection: {
|
|
984
|
+
start: { blockIndex: number; char: number };
|
|
985
|
+
end: { blockIndex: number; char: number };
|
|
986
|
+
} | null = null,
|
|
987
|
+
): boolean => {
|
|
988
|
+
const intent = resolveNavKey(key, {
|
|
989
|
+
isOwner,
|
|
990
|
+
resolved,
|
|
991
|
+
treeActive: railTab === "tree",
|
|
992
|
+
isDiff,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
if (!intent) return false;
|
|
996
|
+
if (intent.type === "cut" && selection) {
|
|
997
|
+
controller.cut(
|
|
998
|
+
selection.start.blockIndex,
|
|
999
|
+
selection.start.char,
|
|
1000
|
+
selection.end.char,
|
|
1001
|
+
selection.end.blockIndex,
|
|
1002
|
+
);
|
|
1003
|
+
} else dispatch(intent);
|
|
1004
|
+
|
|
1005
|
+
return true;
|
|
1006
|
+
};
|
|
1007
|
+
// clicking a sidebar thread moves the cursor onto it too, so the row shows its selected backdrop at once
|
|
1008
|
+
const openThread = (id: string): void => {
|
|
1009
|
+
// save the open editor into the leaving thread's working copy before the switch, so a click away never drops edits
|
|
1010
|
+
if (bodyEditing.editing) bodyEditing.requestExit();
|
|
1011
|
+
const index = grouped.ordered.findIndex((thread) => thread.id === id);
|
|
1012
|
+
|
|
1013
|
+
if (index >= 0) setInboxCursor(() => index);
|
|
1014
|
+
controller.open(id);
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
const createEmptyThread = enabledThreadAction(
|
|
1018
|
+
canRunThreadAgent({
|
|
1019
|
+
enabled: threadAgentEnabled,
|
|
1020
|
+
owner: isOwner,
|
|
1021
|
+
editing: false,
|
|
1022
|
+
shared: isSharedThreadConnection(openClient, shareTransport),
|
|
1023
|
+
}),
|
|
1024
|
+
() => {
|
|
1025
|
+
void controller.createEmptyThread();
|
|
1026
|
+
setFocusedPane("thread");
|
|
1027
|
+
},
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
const threadSurfaceHandledKey = (key: KeyEvent): boolean => {
|
|
1031
|
+
if (
|
|
1032
|
+
agentOwnsKeyboard(agentActive, focusedPane) &&
|
|
1033
|
+
(key.name === "return" || key.name === "enter") &&
|
|
1034
|
+
key.ctrl &&
|
|
1035
|
+
!key.meta &&
|
|
1036
|
+
!key.super
|
|
1037
|
+
)
|
|
1038
|
+
return true;
|
|
1039
|
+
if (!threadViewActive || threadViewSuspended) return false;
|
|
1040
|
+
if (!threadComposing) {
|
|
1041
|
+
const chord = resolveSessionChord(key, { isOwner, resolved });
|
|
1042
|
+
|
|
1043
|
+
if (chord) dispatch(chord);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
return true;
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
useKeyboard((key) => {
|
|
1050
|
+
if (quitKeyHandled(key, onExit)) return;
|
|
1051
|
+
// the inline body editor owns the pane and every key while open
|
|
1052
|
+
if (bodyEditing.editing) return;
|
|
1053
|
+
// the share dialog owns its own keys while open; the shell grammar stands down
|
|
1054
|
+
if (shareDialogOpen) return;
|
|
1055
|
+
if (menuModalHandled(menuControl, key)) return;
|
|
1056
|
+
if (paneCycleRequested(key, overlay, menuOwnsKeyboard, threadComposing)) {
|
|
1057
|
+
return cyclePanes(Boolean(key.shift));
|
|
1058
|
+
}
|
|
1059
|
+
if (
|
|
1060
|
+
threadsNavHandled({
|
|
1061
|
+
focusedPane,
|
|
1062
|
+
quiet: overlay === "none" && !menuOwnsKeyboard && !threadComposing,
|
|
1063
|
+
key,
|
|
1064
|
+
count: grouped.ordered.length,
|
|
1065
|
+
setInboxCursor,
|
|
1066
|
+
openSession: () => dispatch({ type: "openSession" }),
|
|
1067
|
+
})
|
|
1068
|
+
)
|
|
1069
|
+
return;
|
|
1070
|
+
if (threadSurfaceHandledKey(key)) return;
|
|
1071
|
+
// A compose textarea owns the keyboard while open: let it receive the typed note instead of the
|
|
1072
|
+
// global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
|
|
1073
|
+
if (prototypeComposing || welcomeComposing) return;
|
|
1074
|
+
if (menuDialog === "settings") return void handleSettingsKey(key.name);
|
|
1075
|
+
if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
|
|
1076
|
+
// the toast is non-modal: escape only dismisses it when nothing else owns
|
|
1077
|
+
// escape, so an open overlay (compose, submit, prompt, walk) still cancels
|
|
1078
|
+
if (toastDismissRequested(toast !== null, key, overlay, mode.type === "span"))
|
|
1079
|
+
return controller.dismissToast();
|
|
1080
|
+
if (bareShellDefersKeys(session === null, overlay === "none", focusedPane)) return;
|
|
1081
|
+
const state = buildKeyState({
|
|
1082
|
+
keys: keysRef.current,
|
|
1083
|
+
observer,
|
|
1084
|
+
isOwner,
|
|
1085
|
+
overlay,
|
|
1086
|
+
session,
|
|
1087
|
+
isDiff,
|
|
1088
|
+
mode,
|
|
1089
|
+
resolved,
|
|
1090
|
+
inbox,
|
|
1091
|
+
focusedAnnotationId,
|
|
1092
|
+
walk,
|
|
1093
|
+
walkFileList,
|
|
1094
|
+
rows,
|
|
1095
|
+
cursor,
|
|
1096
|
+
display,
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
keyBindings.setContext({
|
|
1100
|
+
overlay: state.overlay,
|
|
1101
|
+
spanMode: state.spanMode,
|
|
1102
|
+
});
|
|
1103
|
+
const action = keyBindings.resolveAction({
|
|
1104
|
+
name: key.name,
|
|
1105
|
+
shift: !!key.shift,
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
for (const intent of reduceKey(
|
|
1109
|
+
state,
|
|
1110
|
+
{ name: key.name, shift: !!key.shift, meta: !!key.meta },
|
|
1111
|
+
action,
|
|
1112
|
+
))
|
|
1113
|
+
dispatch(intent);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// after every keyboard hook above, so their subscriptions precede the signal
|
|
1117
|
+
useReadySignal(usableScreenReached(error, session, inbox), onReady);
|
|
1118
|
+
|
|
1119
|
+
// ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
|
|
1120
|
+
// reused by the inbox and by plan/diff review so the two never drift ──
|
|
1121
|
+
const menuChrome = (
|
|
1122
|
+
<MenuChrome
|
|
1123
|
+
menuDialog={menuDialog}
|
|
1124
|
+
theme={theme}
|
|
1125
|
+
keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
|
|
1126
|
+
settingsCategories={settingsCategories}
|
|
1127
|
+
settingsValues={settingsValues}
|
|
1128
|
+
settingsNav={settingsNav}
|
|
1129
|
+
onCategorySelect={onCategorySelect}
|
|
1130
|
+
cycleSetting={cycleSetting}
|
|
1131
|
+
onClose={() => setMenuDialog(null)}
|
|
1132
|
+
/>
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
// ── render ──────────────────────────────────
|
|
1136
|
+
if (error) return <ErrorScreen error={error} theme={theme} />;
|
|
1137
|
+
if (!session)
|
|
1138
|
+
return inbox ? (
|
|
1139
|
+
<SlashSkillsContext.Provider value={skills}>
|
|
1140
|
+
<PaletteNamesContext.Provider value={paletteNames}>
|
|
1141
|
+
<NoThreadShell
|
|
1142
|
+
toast={toast}
|
|
1143
|
+
extensionRegistry={extensionRegistry}
|
|
1144
|
+
extensionContext={extensionContext(cwd, null)}
|
|
1145
|
+
rows={grouped.rows}
|
|
1146
|
+
inboxCursor={inboxCursor}
|
|
1147
|
+
onOpenThread={openThread}
|
|
1148
|
+
onCreateThread={createEmptyThread}
|
|
1149
|
+
mode={mode}
|
|
1150
|
+
theme={theme}
|
|
1151
|
+
controller={controller}
|
|
1152
|
+
setMode={setMode}
|
|
1153
|
+
menuChrome={menuChrome}
|
|
1154
|
+
onOpenMenu={openSettings}
|
|
1155
|
+
sidebarOpen={sidebarOpen}
|
|
1156
|
+
onToggleSidebar={() => setSidebarOpen((open) => !open)}
|
|
1157
|
+
focusedPane={focusedPane}
|
|
1158
|
+
onFocusPane={setFocusedPane}
|
|
1159
|
+
menuControl={menuControl}
|
|
1160
|
+
pinnedIds={pinnedIds}
|
|
1161
|
+
onPin={togglePin}
|
|
1162
|
+
onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
|
|
1163
|
+
quickActions={quickActions}
|
|
1164
|
+
diffView={diffView}
|
|
1165
|
+
onWelcomeComposingChange={setWelcomeComposing}
|
|
1166
|
+
layout={layout}
|
|
1167
|
+
/>
|
|
1168
|
+
</PaletteNamesContext.Provider>
|
|
1169
|
+
</SlashSkillsContext.Provider>
|
|
1170
|
+
) : (
|
|
1171
|
+
<ConnectingScreen theme={theme} />
|
|
1172
|
+
);
|
|
1173
|
+
|
|
1174
|
+
const activeSession = session;
|
|
1175
|
+
const resolveAuthorLabel = authorLabelResolver(
|
|
1176
|
+
identity.name,
|
|
1177
|
+
activeSession.participants,
|
|
1178
|
+
authorNames,
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
if (isCompletionOverlayPhase(completion) && activeSession.message)
|
|
1182
|
+
return (
|
|
1183
|
+
<CompletionScreen
|
|
1184
|
+
theme={theme}
|
|
1185
|
+
session={activeSession}
|
|
1186
|
+
message={activeSession.message.outcome}
|
|
1187
|
+
completion={completion}
|
|
1188
|
+
status={status}
|
|
1189
|
+
onClose={() => dispatch({ type: "finishReview" })}
|
|
1190
|
+
onBackToPlan={() => dispatch({ type: "dismissCompletion" })}
|
|
1191
|
+
onAlways={() => dispatch({ type: "optInAutoClose" })}
|
|
1192
|
+
/>
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
const diffFold: DiffFoldControls = {
|
|
1196
|
+
isCollapsed: (file) => controller.isFileCollapsed(file),
|
|
1197
|
+
isExpanded: (file) => controller.isFileExpanded(file),
|
|
1198
|
+
canExpand: (file) => controller.canExpandFile(file),
|
|
1199
|
+
onToggleCollapse: (file) =>
|
|
1200
|
+
controller.setFileCollapsed(file, !controller.isFileCollapsed(file)),
|
|
1201
|
+
onToggleExpand: (file) => controller.setFileExpanded(file, !controller.isFileExpanded(file)),
|
|
1202
|
+
onCopyPath: (file) => controller.copyFilePath(file),
|
|
1203
|
+
};
|
|
1204
|
+
const submitConfirmState = buildSubmitConfirmState({
|
|
1205
|
+
mode,
|
|
1206
|
+
isDiff,
|
|
1207
|
+
session: activeSession,
|
|
1208
|
+
walkFileList,
|
|
1209
|
+
viewedPaths,
|
|
1210
|
+
quickActions,
|
|
1211
|
+
liveInput,
|
|
1212
|
+
setMode,
|
|
1213
|
+
dispatch,
|
|
1214
|
+
});
|
|
1215
|
+
const { showOwnerActions, showDiffRefresh, prototypeCanComment, chromeHidden, prototypePath } =
|
|
1216
|
+
buildRenderFlags({
|
|
1217
|
+
session: activeSession,
|
|
1218
|
+
isOwner,
|
|
1219
|
+
isDiff,
|
|
1220
|
+
isPixelPrototype,
|
|
1221
|
+
resolved,
|
|
1222
|
+
menuDialog,
|
|
1223
|
+
resolvedIds,
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
const onEditRequest = (): void => {
|
|
1227
|
+
// A share viewer/observer has no Edit affordance (the button is hidden), so
|
|
1228
|
+
// this is owner-only; stay silent rather than nag if it is ever reached.
|
|
1229
|
+
if (!isOwner) return;
|
|
1230
|
+
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
1231
|
+
openBodyEditor();
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// clicking the rail Submit button: same read-only answer as the submit key
|
|
1235
|
+
const onSubmitRequest = (): void => {
|
|
1236
|
+
if (observer) return controller.setStatus("observer - read-only");
|
|
1237
|
+
if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
|
|
1238
|
+
if (resolved) return;
|
|
1239
|
+
dispatch({ type: "openSubmit" });
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
const threadFooter = (
|
|
1243
|
+
<ThreadFooter
|
|
1244
|
+
controls={threadAgentControls(agentActive, agentControls)}
|
|
1245
|
+
repo={projectName(activeSession.workspace)}
|
|
1246
|
+
branch={activeSession.workspace.branch}
|
|
1247
|
+
onSubmit={onSubmitRequest}
|
|
1248
|
+
canSubmit={canSubmitReview(isOwner, resolved, observer)}
|
|
1249
|
+
pendingAnnotations={
|
|
1250
|
+
activeSession.annotations.filter(
|
|
1251
|
+
(annotation) => !isAddressed(annotation) && !isAgentNote(annotation),
|
|
1252
|
+
).length
|
|
1253
|
+
}
|
|
1254
|
+
theme={theme}
|
|
1255
|
+
/>
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
return (
|
|
1259
|
+
<SlashSkillsContext.Provider value={skills}>
|
|
1260
|
+
<PaletteNamesContext.Provider value={paletteNames}>
|
|
1261
|
+
<ThemeProvider theme={theme}>
|
|
1262
|
+
<MenuControlProvider value={menuControl}>
|
|
1263
|
+
<AppShell
|
|
1264
|
+
extensionRegistry={extensionRegistry}
|
|
1265
|
+
extensionContext={{
|
|
1266
|
+
workspace: activeSession.workspace.repoRoot,
|
|
1267
|
+
threadId: activeSession.id,
|
|
1268
|
+
}}
|
|
1269
|
+
onExtensionError={showExtensionError}
|
|
1270
|
+
sidebarOpen={sidebarOpen}
|
|
1271
|
+
onToggleSidebar={() => setSidebarOpen((open) => !open)}
|
|
1272
|
+
onOpenMenu={openSettings}
|
|
1273
|
+
onFocusPane={setFocusedPane}
|
|
1274
|
+
focusedPane={focusedPane}
|
|
1275
|
+
threadsPanel={
|
|
1276
|
+
<scrollbox style={{ flexGrow: 1 }} focused={false}>
|
|
1277
|
+
<ThreadTree
|
|
1278
|
+
rows={grouped.rows}
|
|
1279
|
+
cursor={inboxCursor}
|
|
1280
|
+
activeId={activeSession.id}
|
|
1281
|
+
focused={focusedPane === "threads"}
|
|
1282
|
+
pinnedIds={pinnedIds}
|
|
1283
|
+
width={30}
|
|
1284
|
+
onSelect={openThread}
|
|
1285
|
+
onCreateThread={createEmptyThread}
|
|
1286
|
+
onPin={togglePin}
|
|
1287
|
+
onRename={(id, title) =>
|
|
1288
|
+
setMode({ type: "renameThread", sessionId: id, text: title })
|
|
1289
|
+
}
|
|
1290
|
+
onRequestDelete={(id, title) =>
|
|
1291
|
+
setMode({ type: "confirmDelete", sessionId: id, title })
|
|
1292
|
+
}
|
|
1293
|
+
theme={theme}
|
|
1294
|
+
/>
|
|
1295
|
+
</scrollbox>
|
|
1296
|
+
}
|
|
1297
|
+
threadTitle={reviewThreadTitle(activeSession)}
|
|
1298
|
+
threadActions={
|
|
1299
|
+
<>
|
|
1300
|
+
{showOwnerActions
|
|
1301
|
+
? ownerThreadActions({
|
|
1302
|
+
editing: bodyEditing.editing,
|
|
1303
|
+
onEdit: onEditRequest,
|
|
1304
|
+
onExitEdit: bodyEditing.requestExit,
|
|
1305
|
+
onShare: () => dispatch({ type: "share" }),
|
|
1306
|
+
theme,
|
|
1307
|
+
})
|
|
1308
|
+
: showDiffRefresh
|
|
1309
|
+
? refreshDiffAction(() => void controller.refreshDiff(), theme)
|
|
1310
|
+
: undefined}
|
|
1311
|
+
</>
|
|
1312
|
+
}
|
|
1313
|
+
threadPanel={
|
|
1314
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
1315
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
1316
|
+
<AgentThreadPrototype
|
|
1317
|
+
key={activeSession.id}
|
|
1318
|
+
enabled={canRunThreadAgent({
|
|
1319
|
+
enabled: threadAgentEnabled,
|
|
1320
|
+
owner: isOwner,
|
|
1321
|
+
editing: bodyEditing.editing,
|
|
1322
|
+
shared: isSharedThreadConnection(openClient, shareTransport),
|
|
1323
|
+
})}
|
|
1324
|
+
observer={observer}
|
|
1325
|
+
pixelPrototype={isPixelPrototype}
|
|
1326
|
+
thread={activeSession}
|
|
1327
|
+
home={home}
|
|
1328
|
+
theme={theme}
|
|
1329
|
+
focused={focusedPane === "thread"}
|
|
1330
|
+
suspended={threadViewSuspended}
|
|
1331
|
+
onActiveChange={setAgentActive}
|
|
1332
|
+
onControlsChange={setAgentControls}
|
|
1333
|
+
onStateChange={setAgentState}
|
|
1334
|
+
onActionsChange={(actions) => {
|
|
1335
|
+
agentActions.current = actions;
|
|
1336
|
+
}}
|
|
1337
|
+
onInvokeChange={(invoke) => {
|
|
1338
|
+
agentInvoke.current = invoke;
|
|
1339
|
+
}}
|
|
1340
|
+
onRevealReply={() => setFocusedPane("thread")}
|
|
1341
|
+
flushMutations={() => flushReviewMutations(controller)}
|
|
1342
|
+
onOpenFile={(path) => workbench.openFile(path, "contents", true)}
|
|
1343
|
+
onNextPane={cyclePanes}
|
|
1344
|
+
>
|
|
1345
|
+
{chooseThreadBody({
|
|
1346
|
+
isPixelPrototype,
|
|
1347
|
+
isDiff,
|
|
1348
|
+
editingBody: bodyEditing.editing,
|
|
1349
|
+
prototype: (
|
|
1350
|
+
<PrototypePixels
|
|
1351
|
+
prototypePath={prototypePath}
|
|
1352
|
+
canComment={prototypeCanComment}
|
|
1353
|
+
onCommentElement={onCommentPrototype}
|
|
1354
|
+
onComposingChange={setPrototypeComposing}
|
|
1355
|
+
hidden={chromeHidden}
|
|
1356
|
+
/>
|
|
1357
|
+
),
|
|
1358
|
+
diffPlaceholder: activeSession.artifact.meta.prBrief ? (
|
|
1359
|
+
<ThreadView
|
|
1360
|
+
session={pullRequestBriefThread(activeSession)}
|
|
1361
|
+
display={buildDisplay(activeSession.artifact.meta.prBrief)}
|
|
1362
|
+
marks={new Map()}
|
|
1363
|
+
quickActions={quickActions}
|
|
1364
|
+
suspended={threadViewSuspended}
|
|
1365
|
+
resolved
|
|
1366
|
+
observer
|
|
1367
|
+
onComposingChange={() => {}}
|
|
1368
|
+
onObserverBlocked={() => {}}
|
|
1369
|
+
onCursorChange={() => {}}
|
|
1370
|
+
onAnnotate={() => {}}
|
|
1371
|
+
onReply={() => {}}
|
|
1372
|
+
onUpdateAnnotation={() => {}}
|
|
1373
|
+
resolveAuthorLabel={() => undefined}
|
|
1374
|
+
onExit={() => onExit?.(0)}
|
|
1375
|
+
/>
|
|
1376
|
+
) : (
|
|
1377
|
+
<box
|
|
1378
|
+
style={{
|
|
1379
|
+
flexGrow: 1,
|
|
1380
|
+
flexDirection: "column",
|
|
1381
|
+
alignItems: "center",
|
|
1382
|
+
justifyContent: "center",
|
|
1383
|
+
}}
|
|
1384
|
+
>
|
|
1385
|
+
<text fg={theme.textDim}>Select a thread</text>
|
|
1386
|
+
</box>
|
|
1387
|
+
),
|
|
1388
|
+
editor: bodyEditing.renderEditor(theme),
|
|
1389
|
+
threadView: (
|
|
1390
|
+
<ThreadView
|
|
1391
|
+
session={activeSession}
|
|
1392
|
+
suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
|
|
1393
|
+
editOrphanCount={editOrphanCount}
|
|
1394
|
+
onComposingChange={setThreadComposing}
|
|
1395
|
+
onNavCommand={runNavCommand}
|
|
1396
|
+
resolved={resolved}
|
|
1397
|
+
onObserverBlocked={(reason) =>
|
|
1398
|
+
controller.setStatus(
|
|
1399
|
+
reason === "observer"
|
|
1400
|
+
? "observer - read-only"
|
|
1401
|
+
: "review submitted - read-only",
|
|
1402
|
+
)
|
|
1403
|
+
}
|
|
1404
|
+
onCursorChange={setCursor}
|
|
1405
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
1406
|
+
onFocusAnnotation={setFocusedAnnotationId}
|
|
1407
|
+
display={display}
|
|
1408
|
+
marks={marks}
|
|
1409
|
+
quickActions={quickActions}
|
|
1410
|
+
observer={observer}
|
|
1411
|
+
onAnnotate={(span, body) =>
|
|
1412
|
+
controller.annotate(
|
|
1413
|
+
"comment",
|
|
1414
|
+
span.start.blockIndex,
|
|
1415
|
+
span.start.char,
|
|
1416
|
+
span.end.char,
|
|
1417
|
+
body,
|
|
1418
|
+
span.end.blockIndex,
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
onReply={(rootAnnotationId, body) =>
|
|
1422
|
+
controller.reply(rootAnnotationId, body)
|
|
1423
|
+
}
|
|
1424
|
+
onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
|
|
1425
|
+
resolveAuthorLabel={resolveAuthorLabel}
|
|
1426
|
+
onExit={() => onExit?.(0)}
|
|
1427
|
+
/>
|
|
1428
|
+
),
|
|
1429
|
+
})}
|
|
1430
|
+
</AgentThreadPrototype>
|
|
1431
|
+
</box>
|
|
1432
|
+
{threadFooter}
|
|
1433
|
+
</box>
|
|
1434
|
+
}
|
|
1435
|
+
changesOpen={workbench.changesOpen}
|
|
1436
|
+
projectOpen={workbench.projectOpen}
|
|
1437
|
+
onToggleChanges={workbench.toggleChanges}
|
|
1438
|
+
onToggleProject={workbench.toggleProject}
|
|
1439
|
+
onToggleRight={workbench.toggleRight}
|
|
1440
|
+
projectMode={workbench.projectMode}
|
|
1441
|
+
zoomHideThread={workbench.zoomed}
|
|
1442
|
+
changesFooter={threadFooter}
|
|
1443
|
+
changesPanel={
|
|
1444
|
+
<EditorGrid
|
|
1445
|
+
tree={workbench.grid}
|
|
1446
|
+
focusedGroupId={workbench.activeGroup}
|
|
1447
|
+
commentCounts={diffCommentCounts}
|
|
1448
|
+
onFocusGroup={workbench.focusGroup}
|
|
1449
|
+
onActivateTab={workbench.activate}
|
|
1450
|
+
onKeepTab={workbench.keepTab}
|
|
1451
|
+
onCloseTab={workbench.close}
|
|
1452
|
+
onSplit={workbench.split}
|
|
1453
|
+
onZoom={workbench.toggleZoom}
|
|
1454
|
+
zoomed={workbench.zoomed}
|
|
1455
|
+
renderTab={(tab, groupFocused) => (
|
|
1456
|
+
<GridTabContent
|
|
1457
|
+
tab={tab}
|
|
1458
|
+
rows={rows}
|
|
1459
|
+
surface={{
|
|
1460
|
+
session: activeSession,
|
|
1461
|
+
quickActions,
|
|
1462
|
+
observer,
|
|
1463
|
+
commentsEnabled: true,
|
|
1464
|
+
resolved,
|
|
1465
|
+
suspended: surfaceSuspended(
|
|
1466
|
+
threadViewSuspended || !groupFocused,
|
|
1467
|
+
focusedPane,
|
|
1468
|
+
"changes",
|
|
1469
|
+
),
|
|
1470
|
+
onComposingChange: setThreadComposing,
|
|
1471
|
+
onObserverBlocked: (reason) =>
|
|
1472
|
+
controller.setStatus(
|
|
1473
|
+
reason === "observer"
|
|
1474
|
+
? "observer - read-only"
|
|
1475
|
+
: "review submitted - read-only",
|
|
1476
|
+
),
|
|
1477
|
+
onCursorChange: setCursor,
|
|
1478
|
+
focusedAnnotationId,
|
|
1479
|
+
onFocusAnnotation: setFocusedAnnotationId,
|
|
1480
|
+
// a diff thread's Changes view is the artifact itself; any other thread's is the
|
|
1481
|
+
// live working-tree diff, so its notes carry a file target and anchor there
|
|
1482
|
+
onAnnotate: (span, body) =>
|
|
1483
|
+
void controller.annotate(
|
|
1484
|
+
"comment",
|
|
1485
|
+
span.start.blockIndex,
|
|
1486
|
+
span.start.char,
|
|
1487
|
+
span.end.char,
|
|
1488
|
+
body,
|
|
1489
|
+
span.end.blockIndex,
|
|
1490
|
+
isDiff
|
|
1491
|
+
? { kind: "artifact" }
|
|
1492
|
+
: { kind: "file", path: "", rev: "worktree" },
|
|
1493
|
+
),
|
|
1494
|
+
onReply: (rootAnnotationId, body) =>
|
|
1495
|
+
void controller.reply(rootAnnotationId, body),
|
|
1496
|
+
onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
|
|
1497
|
+
onInvoke: agentActive
|
|
1498
|
+
? () => {
|
|
1499
|
+
agentInvoke.current?.();
|
|
1500
|
+
setFocusedPane("thread");
|
|
1501
|
+
}
|
|
1502
|
+
: undefined,
|
|
1503
|
+
annotationAction: (id) => agentActions.current(id),
|
|
1504
|
+
isAnnotationReadOnly: (id) =>
|
|
1505
|
+
isSubmittedComment(agentState, activeSession.id, id),
|
|
1506
|
+
resolveAuthorLabel,
|
|
1507
|
+
onNavCommand: runNavCommand,
|
|
1508
|
+
onExit: () => onExit?.(0),
|
|
1509
|
+
}}
|
|
1510
|
+
rejectedRows={rejectedRows}
|
|
1511
|
+
fold={diffFold}
|
|
1512
|
+
fileStats={controller.fileStats()}
|
|
1513
|
+
split={diffView === "split" && workbench.zoomed}
|
|
1514
|
+
dimmed={walking}
|
|
1515
|
+
readFile={(path) => controller.repoReadFile(path)}
|
|
1516
|
+
onAddFileComment={(path, anchor, body) =>
|
|
1517
|
+
void controller.addComment(
|
|
1518
|
+
anchor,
|
|
1519
|
+
{ kind: "file", path, rev: "worktree" },
|
|
1520
|
+
body,
|
|
1521
|
+
)
|
|
1522
|
+
}
|
|
1523
|
+
// zoomed, the footer already rides the Changes pane, so no extra offset is needed
|
|
1524
|
+
emptyBottomPadding={workbench.zoomed ? 0 : THREAD_FOOTER_HEIGHT}
|
|
1525
|
+
theme={theme}
|
|
1526
|
+
/>
|
|
1527
|
+
)}
|
|
1528
|
+
theme={theme}
|
|
1529
|
+
/>
|
|
1530
|
+
}
|
|
1531
|
+
projectPanel={
|
|
1532
|
+
<ProjectPanelBody
|
|
1533
|
+
mode={workbench.projectMode}
|
|
1534
|
+
loadChanges={() => controller.repoChanges()}
|
|
1535
|
+
initialFiles={initialReviewFiles(
|
|
1536
|
+
initialReviewForFirstMount.current,
|
|
1537
|
+
activeSession.id,
|
|
1538
|
+
)}
|
|
1539
|
+
loadProjectFiles={() => controller.repoFiles()}
|
|
1540
|
+
diffSourceKey={activeSession.id}
|
|
1541
|
+
{...projectDiffFiles(activeSession)}
|
|
1542
|
+
// the Changes navigator always opens a changed file as a diff - a diff review shows its
|
|
1543
|
+
// captured snapshot, every other thread the live working-tree diff
|
|
1544
|
+
onOpenChangedFile={(path, persistent) =>
|
|
1545
|
+
workbench.openFile(path, "diff", persistent)
|
|
1546
|
+
}
|
|
1547
|
+
onOpenProjectFile={(path, persistent) =>
|
|
1548
|
+
workbench.openFile(path, "contents", persistent)
|
|
1549
|
+
}
|
|
1550
|
+
commentCounts={diffCommentCounts}
|
|
1551
|
+
focused={focusedPane === "project"}
|
|
1552
|
+
theme={theme}
|
|
1553
|
+
/>
|
|
1554
|
+
}
|
|
1555
|
+
theme={theme}
|
|
1556
|
+
>
|
|
1557
|
+
<TrailingOverlays
|
|
1558
|
+
walking={walking}
|
|
1559
|
+
walk={walk}
|
|
1560
|
+
walkFileList={walkFileList}
|
|
1561
|
+
viewedPaths={viewedPaths}
|
|
1562
|
+
session={activeSession}
|
|
1563
|
+
terminalWidth={terminalWidth}
|
|
1564
|
+
theme={theme}
|
|
1565
|
+
mode={mode}
|
|
1566
|
+
toast={toast}
|
|
1567
|
+
onDismissToast={() => controller.dismissToast()}
|
|
1568
|
+
setMode={setMode}
|
|
1569
|
+
dispatch={dispatch}
|
|
1570
|
+
/>
|
|
1571
|
+
{submitConfirmState !== null ? (
|
|
1572
|
+
<box
|
|
1573
|
+
onMouseUp={submitConfirmState.onCancel}
|
|
1574
|
+
style={{
|
|
1575
|
+
position: "absolute",
|
|
1576
|
+
left: 0,
|
|
1577
|
+
top: 0,
|
|
1578
|
+
width: "100%",
|
|
1579
|
+
height: "100%",
|
|
1580
|
+
justifyContent: "center",
|
|
1581
|
+
alignItems: "center",
|
|
1582
|
+
}}
|
|
1583
|
+
>
|
|
1584
|
+
<box onMouseUp={(event) => event.stopPropagation()}>
|
|
1585
|
+
<ConfirmCard {...submitConfirmState} theme={theme} />
|
|
1586
|
+
</box>
|
|
1587
|
+
</box>
|
|
1588
|
+
) : null}
|
|
1589
|
+
<ShareDialog
|
|
1590
|
+
key={String(shareDialogOpen)}
|
|
1591
|
+
isOpen={shareDialogOpen}
|
|
1592
|
+
threadName={shareThreadName(session)}
|
|
1593
|
+
links={shareLinksFor(session)}
|
|
1594
|
+
isOwner={isOwner}
|
|
1595
|
+
onCreateLink={(input) => controller.createShareLink(input)}
|
|
1596
|
+
onUpdateLink={(id, input) => controller.updateShareLink(id, input)}
|
|
1597
|
+
onDeleteLink={(id) => controller.deleteShareLink(id)}
|
|
1598
|
+
onCopyLink={(id) => controller.copyShareLink(id)}
|
|
1599
|
+
onClose={() => setShareDialogOpen(false)}
|
|
1600
|
+
theme={theme}
|
|
1601
|
+
/>
|
|
1602
|
+
{menuChrome}
|
|
1603
|
+
</AppShell>
|
|
1604
|
+
</MenuControlProvider>
|
|
1605
|
+
</ThemeProvider>
|
|
1606
|
+
</PaletteNamesContext.Provider>
|
|
1607
|
+
</SlashSkillsContext.Provider>
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function enabledThreadAction(enabled: boolean, action: () => void): (() => void) | undefined {
|
|
1612
|
+
return enabled ? action : undefined;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** Experimental agent UI is available only in a local owner's review. */
|
|
1616
|
+
export function canRunThreadAgent(options: {
|
|
1617
|
+
enabled: boolean;
|
|
1618
|
+
owner: boolean;
|
|
1619
|
+
editing: boolean;
|
|
1620
|
+
shared: boolean;
|
|
1621
|
+
}): boolean {
|
|
1622
|
+
return options.enabled && options.owner && !options.editing && !options.shared;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function isSubmittedComment(
|
|
1626
|
+
state: import("@cueloop/schema").ThreadAgentState | undefined,
|
|
1627
|
+
threadId: string,
|
|
1628
|
+
commentId: string,
|
|
1629
|
+
): boolean {
|
|
1630
|
+
return (
|
|
1631
|
+
state?.threadId === threadId &&
|
|
1632
|
+
Boolean(state.submissions?.some((entry) => entry.commentId === commentId))
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function flushReviewMutations(controller: ReviewController): Promise<void> {
|
|
1637
|
+
return controller.flushMutations?.() ?? Promise.resolve();
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function threadAgentControls(active: boolean, controls: React.ReactNode): React.ReactNode {
|
|
1641
|
+
return active ? controls : undefined;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Injected shared transports never own a local harness connection. */
|
|
1645
|
+
function isSharedThreadConnection(
|
|
1646
|
+
openClient: AppProps["openClient"],
|
|
1647
|
+
shareTransport: ShareTransport | undefined,
|
|
1648
|
+
): boolean {
|
|
1649
|
+
return Boolean(openClient || shareTransport);
|
|
1650
|
+
}
|