@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90
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 +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1402 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +774 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -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 +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -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 +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -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 +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -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/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -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 +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -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 +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -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 +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -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/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -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 +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -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/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -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 +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -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 +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -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/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -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 +238 -0
- package/src/diff-intraline.ts +236 -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/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +28 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +67 -0
- package/src/launch-layout.ts +47 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -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 +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -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 +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -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 +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- 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 -364
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
2
|
+
import type { Thread, MessageOutcome } from "@cueloop/schema";
|
|
3
|
+
import type { Mode } from "./intent-dispatch";
|
|
4
|
+
import type { Intent, KeyState } from "./keymap";
|
|
5
|
+
import type { Completion } from "./thread-controller";
|
|
6
|
+
import type { WalkFile } from "./walk";
|
|
7
|
+
import type { QuickAction } from "./config";
|
|
8
|
+
import { viewedCount } from "./walk";
|
|
9
|
+
import type { ConfirmCardProps } from "./components/ConfirmCard";
|
|
10
|
+
import type { BreadcrumbItem } from "./components/Breadcrumb";
|
|
11
|
+
|
|
12
|
+
export function computeRoleCapabilities(
|
|
13
|
+
readOnly: boolean,
|
|
14
|
+
role: "owner" | "observer" | "collaborator",
|
|
15
|
+
) {
|
|
16
|
+
const observer = readOnly || role === "observer";
|
|
17
|
+
|
|
18
|
+
return { observer, isOwner: !observer && role === "owner" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function deriveReviewFlags(session: Thread | null) {
|
|
22
|
+
return {
|
|
23
|
+
isDiff: session?.artifact.type === "diff",
|
|
24
|
+
isPrototype: session?.artifact.type === "prototype",
|
|
25
|
+
resolved: session?.status === "resolved",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isWalking(isDiff: boolean, walk: { index: number } | null): boolean {
|
|
30
|
+
return isDiff && walk !== null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A prototype renders as a kitty pixel mockup only in the opt-in experimental mode:
|
|
35
|
+
* the flag is on and the artifact carries an HTML entry. Otherwise it is the default
|
|
36
|
+
* markdown design doc, rendered through the thread/markdown path.
|
|
37
|
+
*/
|
|
38
|
+
export function isPixelPrototypeMode(
|
|
39
|
+
isPrototype: boolean,
|
|
40
|
+
prototypePixels: boolean,
|
|
41
|
+
prototypePath: string | undefined,
|
|
42
|
+
): boolean {
|
|
43
|
+
return isPrototype && prototypePixels && Boolean(prototypePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveOverlay(
|
|
47
|
+
mode: Mode,
|
|
48
|
+
completionPhase: Completion["phase"],
|
|
49
|
+
walking: boolean,
|
|
50
|
+
): KeyState["overlay"] {
|
|
51
|
+
if (mode.type === "compose" || mode.type === "railEdit") return "compose";
|
|
52
|
+
if (mode.type === "submit") return "submit";
|
|
53
|
+
if (mode.type === "confirmDelete") return "confirm";
|
|
54
|
+
if (
|
|
55
|
+
mode.type === "rename" ||
|
|
56
|
+
mode.type === "renameThread" ||
|
|
57
|
+
mode.type === "nameSelf" ||
|
|
58
|
+
mode.type === "treePrompt"
|
|
59
|
+
)
|
|
60
|
+
return "prompt";
|
|
61
|
+
if (mode.type === "spanActions") return "spanActions";
|
|
62
|
+
if (completionPhase === "prompt") return "completion-prompt";
|
|
63
|
+
if (completionPhase === "counting") return "completion-counting";
|
|
64
|
+
if (walking) return "walk";
|
|
65
|
+
|
|
66
|
+
return "none";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isCompletionOverlayPhase(
|
|
70
|
+
completion: Completion,
|
|
71
|
+
): completion is { phase: "prompt" } | { phase: "counting"; remaining: number } {
|
|
72
|
+
return completion.phase === "prompt" || completion.phase === "counting";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildHeaderItems(params: {
|
|
76
|
+
session: Thread;
|
|
77
|
+
resolved: boolean;
|
|
78
|
+
observer: boolean;
|
|
79
|
+
role: "owner" | "observer" | "collaborator";
|
|
80
|
+
}): BreadcrumbItem[] {
|
|
81
|
+
const { session, resolved, observer, role } = params;
|
|
82
|
+
|
|
83
|
+
return [
|
|
84
|
+
{ label: "cueloop", tone: "accent" },
|
|
85
|
+
...(resolved
|
|
86
|
+
? [
|
|
87
|
+
{
|
|
88
|
+
label: `resolved: ${session.message!.outcome.replace("_", " ")}`,
|
|
89
|
+
tone: "green" as const,
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
: []),
|
|
93
|
+
...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
|
|
94
|
+
...(role === "collaborator"
|
|
95
|
+
? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
|
|
96
|
+
: []),
|
|
97
|
+
{
|
|
98
|
+
label: `${session.artifact.meta.title ?? session.artifact.meta.planPath ?? session.id} · rev ${session.revisions.length}`,
|
|
99
|
+
tone: "dim",
|
|
100
|
+
},
|
|
101
|
+
{ label: `submitted by ${session.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildRenderFlags(params: {
|
|
106
|
+
session: Thread;
|
|
107
|
+
isOwner: boolean;
|
|
108
|
+
isDiff: boolean;
|
|
109
|
+
isPixelPrototype: boolean;
|
|
110
|
+
resolved: boolean;
|
|
111
|
+
menuDialog: "keybinds" | "settings" | null;
|
|
112
|
+
resolvedIds: Set<string>;
|
|
113
|
+
}) {
|
|
114
|
+
const { session, isOwner, isDiff, isPixelPrototype, resolved, menuDialog, resolvedIds } = params;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
showOwnerActions: isOwner && !isDiff && !resolved,
|
|
118
|
+
showDiffRefresh:
|
|
119
|
+
!resolved &&
|
|
120
|
+
(session.artifact.meta.prRefreshHeadSha !== undefined ||
|
|
121
|
+
(session.artifact.type === "diff" &&
|
|
122
|
+
session.artifact.meta.vcsChangeId !== undefined &&
|
|
123
|
+
session.artifact.meta.workbench !== true)),
|
|
124
|
+
prototypeCanComment: isOwner && !resolved,
|
|
125
|
+
chromeHidden: menuDialog !== null,
|
|
126
|
+
prototypePath: session.artifact.meta.prototypePath ?? "",
|
|
127
|
+
// a markdown prototype interleaves resolved cards like a plan; only the pixel mockup opts out
|
|
128
|
+
railResolvedIds: isDiff || isPixelPrototype ? null : resolvedIds,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface DraftHandlerDeps {
|
|
133
|
+
liveInput: MutableRefObject<string>;
|
|
134
|
+
setMode: Dispatch<SetStateAction<Mode>>;
|
|
135
|
+
dispatch: (intent: Intent) => void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildSubmitConfirmState(
|
|
139
|
+
deps: DraftHandlerDeps & {
|
|
140
|
+
mode: Mode;
|
|
141
|
+
isDiff: boolean;
|
|
142
|
+
session: Thread;
|
|
143
|
+
walkFileList: WalkFile[];
|
|
144
|
+
viewedPaths: Set<string>;
|
|
145
|
+
quickActions: QuickAction[];
|
|
146
|
+
},
|
|
147
|
+
): Omit<ConfirmCardProps, "theme"> | null {
|
|
148
|
+
const { mode, isDiff, session, walkFileList, viewedPaths, liveInput, setMode, dispatch } = deps;
|
|
149
|
+
|
|
150
|
+
if (mode.type !== "submit") return null;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
message: mode.message,
|
|
154
|
+
summary: mode.summary,
|
|
155
|
+
quickActions: deps.quickActions,
|
|
156
|
+
viewedSummary:
|
|
157
|
+
isDiff && session.viewedPaths !== undefined
|
|
158
|
+
? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
|
|
159
|
+
: undefined,
|
|
160
|
+
onInput: (summary: string) => {
|
|
161
|
+
liveInput.current = summary;
|
|
162
|
+
setMode({ ...mode, summary });
|
|
163
|
+
},
|
|
164
|
+
onSelectMessage: (message: MessageOutcome) => setMode({ ...mode, message }),
|
|
165
|
+
onSubmit: () => dispatch({ type: "submitMessage" }),
|
|
166
|
+
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Identity } from "@cueloop/schema";
|
|
3
|
+
import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
|
|
4
|
+
|
|
5
|
+
describe(shortHandle, () => {
|
|
6
|
+
test("shortens an ssh fingerprint to a stable handle", () => {
|
|
7
|
+
// Arrange / Act / Assert
|
|
8
|
+
expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
|
|
9
|
+
expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe(resolveDisplayName, () => {
|
|
14
|
+
const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
|
|
15
|
+
const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
|
|
16
|
+
|
|
17
|
+
test("a local rename override wins over everything", () => {
|
|
18
|
+
// Arrange / Act
|
|
19
|
+
const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
|
|
20
|
+
|
|
21
|
+
// Assert
|
|
22
|
+
expect(label).toBe("Alex");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("falls back to name, then anonymous, then the short handle", () => {
|
|
26
|
+
// Arrange / Act / Assert
|
|
27
|
+
expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
|
|
28
|
+
expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
|
|
29
|
+
expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribution labels for annotations. An annotation's `author` is an identity
|
|
3
|
+
* id (an SSH fingerprint today). The rail resolves it against the share's
|
|
4
|
+
* participant registry and the planner's local rename overrides; ADR 0006.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Identity } from "@cueloop/schema";
|
|
8
|
+
|
|
9
|
+
const FINGERPRINT_PREFIX = "SHA256:";
|
|
10
|
+
|
|
11
|
+
/** Shown for a known participant who chose not to give a name. */
|
|
12
|
+
export const ANONYMOUS_LABEL = "-- anonymous --";
|
|
13
|
+
|
|
14
|
+
/** A short, stable handle from a raw author id when no name is known. */
|
|
15
|
+
export function shortHandle(authorId: string): string {
|
|
16
|
+
const base = authorId.startsWith(FINGERPRINT_PREFIX)
|
|
17
|
+
? authorId.slice(FINGERPRINT_PREFIX.length)
|
|
18
|
+
: authorId;
|
|
19
|
+
|
|
20
|
+
return base.slice(0, 8) || authorId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The rail label for an author id: a local rename wins, then the collaborator's
|
|
25
|
+
* own name, then anonymous for a known-but-unnamed participant, then a short
|
|
26
|
+
* handle for an id we hold no identity for.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveDisplayName(
|
|
29
|
+
authorId: string,
|
|
30
|
+
participants: Identity[] | undefined,
|
|
31
|
+
overrides: Record<string, string>,
|
|
32
|
+
): string {
|
|
33
|
+
const override = overrides[authorId];
|
|
34
|
+
|
|
35
|
+
if (override) return override;
|
|
36
|
+
const identity = participants?.find((participant) => participant.id === authorId);
|
|
37
|
+
|
|
38
|
+
if (identity?.name) return identity.name;
|
|
39
|
+
if (identity) return ANONYMOUS_LABEL;
|
|
40
|
+
|
|
41
|
+
return shortHandle(authorId);
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { ClientExtensionRegistry } from "./client-extension-registry";
|
|
4
|
+
|
|
5
|
+
const Component = () => <></>;
|
|
6
|
+
|
|
7
|
+
describe("client extension registry", () => {
|
|
8
|
+
test("registers each contribution and removes it on disposal", async () => {
|
|
9
|
+
const registry = new ClientExtensionRegistry();
|
|
10
|
+
const before = registry.snapshot();
|
|
11
|
+
let notifications = 0;
|
|
12
|
+
const unsubscribe = registry.subscribe(() => notifications++);
|
|
13
|
+
const loaded = await registry.load("example", (api) => {
|
|
14
|
+
api.registerSection({ id: "summary", zone: "threads.sidebar", title: "Summary", Component });
|
|
15
|
+
api.registerAction({ id: "open", zone: "thread.header", label: "Open", onPress() {} });
|
|
16
|
+
api.registerView({ id: "details", zone: "workspace.panels", title: "Details", Component });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(registry.snapshot()).not.toBe(before);
|
|
20
|
+
expect(registry.snapshot().sections.map(({ key }) => key)).toEqual(["example:summary"]);
|
|
21
|
+
expect(registry.snapshot().actions.map(({ key }) => key)).toEqual(["example:open"]);
|
|
22
|
+
expect(registry.snapshot().views.map(({ key }) => key)).toEqual(["example:details"]);
|
|
23
|
+
loaded.dispose();
|
|
24
|
+
unsubscribe();
|
|
25
|
+
expect(registry.snapshot().sections).toEqual([]);
|
|
26
|
+
expect(registry.snapshot().actions).toEqual([]);
|
|
27
|
+
expect(registry.snapshot().views).toEqual([]);
|
|
28
|
+
expect(notifications).toBe(6);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rolls back a factory that fails after registering", async () => {
|
|
32
|
+
const registry = new ClientExtensionRegistry();
|
|
33
|
+
|
|
34
|
+
await registry.load("broken", (api) => {
|
|
35
|
+
api.registerSection({ id: "summary", zone: "threads.sidebar", title: "Summary", Component });
|
|
36
|
+
throw new Error("boom");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(registry.snapshot().sections).toEqual([]);
|
|
40
|
+
expect(registry.errors[0]).toContain("boom");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a disposed registration cannot remove a later registration with the same ID", async () => {
|
|
44
|
+
const registry = new ClientExtensionRegistry();
|
|
45
|
+
let first: { dispose(): void } | undefined;
|
|
46
|
+
|
|
47
|
+
await registry.load("example", (api) => {
|
|
48
|
+
first = api.registerSection({
|
|
49
|
+
id: "summary",
|
|
50
|
+
zone: "threads.sidebar",
|
|
51
|
+
title: "Old",
|
|
52
|
+
Component,
|
|
53
|
+
});
|
|
54
|
+
first.dispose();
|
|
55
|
+
api.registerSection({ id: "summary", zone: "threads.sidebar", title: "New", Component });
|
|
56
|
+
});
|
|
57
|
+
first?.dispose();
|
|
58
|
+
|
|
59
|
+
expect(registry.snapshot().sections[0]?.value.title).toBe("New");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
import { discoverInstalledExtensionPackages } from "@cueloop/extension-api/installed-packages";
|
|
6
|
+
import type {
|
|
7
|
+
ClientExtensionAPI,
|
|
8
|
+
ClientExtensionFactory,
|
|
9
|
+
ExtensionUIDisposable,
|
|
10
|
+
ThreadHeaderAction,
|
|
11
|
+
ThreadSidebarSection,
|
|
12
|
+
WorkspacePanelView,
|
|
13
|
+
} from "@cueloop/extension-api/client";
|
|
14
|
+
|
|
15
|
+
interface OwnedContribution<T> {
|
|
16
|
+
key: string;
|
|
17
|
+
value: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A stable snapshot lets React subscribe without rebuilding contribution arrays on every render. */
|
|
21
|
+
export interface ClientExtensionSnapshot {
|
|
22
|
+
sections: readonly OwnedContribution<ThreadSidebarSection>[];
|
|
23
|
+
actions: readonly OwnedContribution<ThreadHeaderAction>[];
|
|
24
|
+
views: readonly OwnedContribution<WorkspacePanelView>[];
|
|
25
|
+
lastError: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Client extension registrations are isolated by package and disposed on load failure. */
|
|
29
|
+
export class ClientExtensionRegistry {
|
|
30
|
+
private readonly listeners = new Set<() => void>();
|
|
31
|
+
private readonly sections = new Map<string, ThreadSidebarSection>();
|
|
32
|
+
private readonly actions = new Map<string, ThreadHeaderAction>();
|
|
33
|
+
private readonly views = new Map<string, WorkspacePanelView>();
|
|
34
|
+
private current: ClientExtensionSnapshot = {
|
|
35
|
+
sections: [],
|
|
36
|
+
actions: [],
|
|
37
|
+
views: [],
|
|
38
|
+
lastError: null,
|
|
39
|
+
};
|
|
40
|
+
readonly errors: string[] = [];
|
|
41
|
+
|
|
42
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
43
|
+
this.listeners.add(listener);
|
|
44
|
+
|
|
45
|
+
return () => this.listeners.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
readonly snapshot = (): ClientExtensionSnapshot => this.current;
|
|
49
|
+
|
|
50
|
+
private changed(): void {
|
|
51
|
+
this.current = {
|
|
52
|
+
sections: [...this.sections].map(([key, value]) => ({ key, value })),
|
|
53
|
+
actions: [...this.actions].map(([key, value]) => ({ key, value })),
|
|
54
|
+
views: [...this.views].map(([key, value]) => ({ key, value })),
|
|
55
|
+
lastError: this.errors.at(-1) ?? null,
|
|
56
|
+
};
|
|
57
|
+
for (const listener of this.listeners) listener();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private add<T extends { id: string; zone: string }>(
|
|
61
|
+
map: Map<string, T>,
|
|
62
|
+
owner: string,
|
|
63
|
+
contribution: T,
|
|
64
|
+
zone: string,
|
|
65
|
+
): ExtensionUIDisposable {
|
|
66
|
+
if (!/^[a-z][a-z0-9-]*$/.test(contribution.id))
|
|
67
|
+
throw new Error(`Client extension invalid contribution ID: ${contribution.id}`);
|
|
68
|
+
if (contribution.zone !== zone)
|
|
69
|
+
throw new Error(`Client extension ${owner}:${contribution.id} must use zone ${zone}`);
|
|
70
|
+
const key = `${owner}:${contribution.id}`;
|
|
71
|
+
|
|
72
|
+
if (this.sections.has(key) || this.actions.has(key) || this.views.has(key))
|
|
73
|
+
throw new Error(`Client extension duplicate contribution: ${key}`);
|
|
74
|
+
map.set(key, contribution);
|
|
75
|
+
this.changed();
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
dispose: () => {
|
|
79
|
+
if (map.get(key) === contribution) {
|
|
80
|
+
map.delete(key);
|
|
81
|
+
this.changed();
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
reportError(message: string): void {
|
|
88
|
+
this.errors.push(message);
|
|
89
|
+
this.changed();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Load one client factory and remove all of its registrations if it fails. */
|
|
93
|
+
async load(owner: string, factory: ClientExtensionFactory): Promise<ExtensionUIDisposable> {
|
|
94
|
+
const registrations: ExtensionUIDisposable[] = [];
|
|
95
|
+
const api: ClientExtensionAPI = {
|
|
96
|
+
react: React,
|
|
97
|
+
useKeyboard,
|
|
98
|
+
registerSection: (section) => {
|
|
99
|
+
const registration = this.add(this.sections, owner, section, "threads.sidebar");
|
|
100
|
+
|
|
101
|
+
registrations.push(registration);
|
|
102
|
+
|
|
103
|
+
return registration;
|
|
104
|
+
},
|
|
105
|
+
registerAction: (action) => {
|
|
106
|
+
const registration = this.add(this.actions, owner, action, "thread.header");
|
|
107
|
+
|
|
108
|
+
registrations.push(registration);
|
|
109
|
+
|
|
110
|
+
return registration;
|
|
111
|
+
},
|
|
112
|
+
registerView: (view) => {
|
|
113
|
+
const registration = this.add(this.views, owner, view, "workspace.panels");
|
|
114
|
+
|
|
115
|
+
registrations.push(registration);
|
|
116
|
+
|
|
117
|
+
return registration;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await factory(api);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
for (const registration of registrations) registration.dispose();
|
|
125
|
+
this.reportError(`Client extension ${owner}: ${String(error)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
dispose: () => {
|
|
130
|
+
for (const registration of registrations) registration.dispose();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Import only the client entry points of installed extension packages. */
|
|
137
|
+
export async function loadInstalledClientExtensions(
|
|
138
|
+
registry: ClientExtensionRegistry,
|
|
139
|
+
installRoot?: string,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const discovery = discoverInstalledExtensionPackages(installRoot);
|
|
142
|
+
|
|
143
|
+
for (const error of discovery.errors) registry.reportError(error);
|
|
144
|
+
for (const extension of discovery.packages) {
|
|
145
|
+
if (!extension.clientEntry) continue;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const module: unknown = await import(pathToFileURL(extension.clientEntry).href);
|
|
149
|
+
const parsed = v.safeParse(v.object({ default: v.function() }), module);
|
|
150
|
+
|
|
151
|
+
if (!parsed.success) throw new Error("client entry point must export a default factory");
|
|
152
|
+
await registry.load(extension.name, async (api) => {
|
|
153
|
+
await parsed.output.default(api);
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
registry.reportError(`Client extension ${extension.name}: ${String(error)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { clipboardCommands } from "./clipboard";
|
|
3
|
+
|
|
4
|
+
describe(clipboardCommands, () => {
|
|
5
|
+
test("uses pbcopy on macOS", () => {
|
|
6
|
+
// Assert
|
|
7
|
+
expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses clip.exe on Windows", () => {
|
|
11
|
+
// Assert
|
|
12
|
+
expect(clipboardCommands("win32")).toEqual([["clip.exe"]]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("prefers wayland, then X11 tools on Linux", () => {
|
|
16
|
+
// Act
|
|
17
|
+
const commands = clipboardCommands("linux");
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(commands[0]).toEqual(["wl-copy"]);
|
|
21
|
+
expect(commands.map((command) => command[0])).toEqual(["wl-copy", "xclip", "xsel"]);
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort system clipboard. Each platform has its own tool; we try them in
|
|
3
|
+
* order and report whether one worked, so the caller can fall back to printing
|
|
4
|
+
* the line (the over-SSH case, where no clipboard tool exists). Mirrors how
|
|
5
|
+
* herdr confirms a copy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The clipboard-write commands to try, most-preferred first, for this platform. */
|
|
9
|
+
export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
|
|
10
|
+
if (platform === "darwin") return [["pbcopy"]];
|
|
11
|
+
if (platform === "win32") return [["clip.exe"]];
|
|
12
|
+
|
|
13
|
+
return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Copy `text` to the clipboard; false when no tool is available or all fail. */
|
|
17
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
18
|
+
for (const command of clipboardCommands()) {
|
|
19
|
+
try {
|
|
20
|
+
const proc = Bun.spawn(command, {
|
|
21
|
+
stdin: Buffer.from(text),
|
|
22
|
+
stdout: "ignore",
|
|
23
|
+
stderr: "ignore",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if ((await proc.exited) === 0) return true;
|
|
27
|
+
} catch {
|
|
28
|
+
// tool not installed - try the next one
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { SCHEMA_VERSION, type Thread } from "@cueloop/schema";
|
|
3
|
+
import { DARK } from "../theme";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { AnnotatableFileView } from "./AnnotatableFileView";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Surfaces/AnnotatableFileView" };
|
|
8
|
+
|
|
9
|
+
const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
|
|
10
|
+
|
|
11
|
+
const SESSION: Thread = {
|
|
12
|
+
schemaVersion: SCHEMA_VERSION,
|
|
13
|
+
id: "ses_story",
|
|
14
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
15
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
16
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00Z" }],
|
|
17
|
+
annotations: [],
|
|
18
|
+
message: null,
|
|
19
|
+
status: "pending",
|
|
20
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const noop = (): void => {};
|
|
24
|
+
|
|
25
|
+
export const Loaded: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<AnnotatableFileView
|
|
28
|
+
path="src/add.ts"
|
|
29
|
+
loadContents={() => Promise.resolve(SAMPLE)}
|
|
30
|
+
session={SESSION}
|
|
31
|
+
quickActions={[]}
|
|
32
|
+
observer={false}
|
|
33
|
+
onAddComment={noop}
|
|
34
|
+
onReply={noop}
|
|
35
|
+
onUpdateAnnotation={noop}
|
|
36
|
+
onExit={noop}
|
|
37
|
+
theme={DARK}
|
|
38
|
+
/>
|
|
39
|
+
),
|
|
40
|
+
expectedColors: [DARK.textDim],
|
|
41
|
+
size: { width: 60, height: 12 },
|
|
42
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project file is its own annotation surface: selecting a line and typing leaves a comment whose
|
|
3
|
+
* anchor quotes that file line, handed up as an Anchor the app persists with a file target.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { SCHEMA_VERSION, type Anchor, type Thread } from "@cueloop/schema";
|
|
10
|
+
import { AnnotatableFileView } from "./AnnotatableFileView";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { dragText, typeText, pressKey, waitForText } from "../test-support";
|
|
13
|
+
|
|
14
|
+
const SAMPLE = "export function add(a: number, b: number) {\n return a + b;\n}\n";
|
|
15
|
+
|
|
16
|
+
function planSession(): Thread {
|
|
17
|
+
return {
|
|
18
|
+
schemaVersion: SCHEMA_VERSION,
|
|
19
|
+
id: "ses_plan",
|
|
20
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
21
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
22
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00Z" }],
|
|
23
|
+
annotations: [],
|
|
24
|
+
message: null,
|
|
25
|
+
status: "pending",
|
|
26
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const noop = (): void => {};
|
|
31
|
+
|
|
32
|
+
describe("AnnotatableFileView", () => {
|
|
33
|
+
test("commenting a file line hands up an anchor that quotes the line", async () => {
|
|
34
|
+
const onAddComment = mock((_anchor: Anchor, _body: string) => {});
|
|
35
|
+
const setup = await testRender(
|
|
36
|
+
<AnnotatableFileView
|
|
37
|
+
path="src/add.ts"
|
|
38
|
+
loadContents={() => Promise.resolve(SAMPLE)}
|
|
39
|
+
session={planSession()}
|
|
40
|
+
quickActions={[]}
|
|
41
|
+
observer={false}
|
|
42
|
+
onAddComment={onAddComment}
|
|
43
|
+
onReply={noop}
|
|
44
|
+
onUpdateAnnotation={noop}
|
|
45
|
+
onExit={noop}
|
|
46
|
+
theme={DARK}
|
|
47
|
+
/>,
|
|
48
|
+
{ width: 64, height: 12 },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Act - select the middle line, type a comment, send it
|
|
52
|
+
await waitForText(setup, "return a + b");
|
|
53
|
+
await dragText(setup, "return a + b", "return a + b", "return a + b".length);
|
|
54
|
+
await typeText(setup, "why not reduce?");
|
|
55
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
56
|
+
|
|
57
|
+
// Assert - the surface built an anchor quoting the file line and handed it up with the body
|
|
58
|
+
expect(onAddComment).toHaveBeenCalled();
|
|
59
|
+
const [anchor, body] = onAddComment.mock.calls[0]!;
|
|
60
|
+
expect(anchor.quote).toContain("return a + b");
|
|
61
|
+
expect(body).toContain("why not reduce?");
|
|
62
|
+
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a head-side (deleted-line) note never rebinds onto the current file", async () => {
|
|
67
|
+
const session = planSession();
|
|
68
|
+
// a note left on the removed side of this file: the worktree view must not show it
|
|
69
|
+
session.annotations = [
|
|
70
|
+
{
|
|
71
|
+
id: "h1",
|
|
72
|
+
kind: "comment",
|
|
73
|
+
anchor: { quote: "return a + b", prefix: "", suffix: "" },
|
|
74
|
+
target: { kind: "file", path: "src/add.ts", rev: "head" },
|
|
75
|
+
body: "deleted-side note",
|
|
76
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
const setup = await testRender(
|
|
80
|
+
<AnnotatableFileView
|
|
81
|
+
path="src/add.ts"
|
|
82
|
+
loadContents={() => Promise.resolve(SAMPLE)}
|
|
83
|
+
session={session}
|
|
84
|
+
quickActions={[]}
|
|
85
|
+
observer={false}
|
|
86
|
+
onAddComment={noop}
|
|
87
|
+
onReply={noop}
|
|
88
|
+
onUpdateAnnotation={noop}
|
|
89
|
+
onExit={noop}
|
|
90
|
+
theme={DARK}
|
|
91
|
+
/>,
|
|
92
|
+
{ width: 64, height: 12 },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await waitForText(setup, "return a + b");
|
|
96
|
+
|
|
97
|
+
// Assert - the current file shows, but the head-side note is not attached to it
|
|
98
|
+
expect(setup.captureCharFrame()).not.toContain("deleted-side note");
|
|
99
|
+
|
|
100
|
+
setup.renderer.destroy();
|
|
101
|
+
});
|
|
102
|
+
});
|