@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1039 -799
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +461 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +286 -0
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
package/src/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
|
+
verdict: 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
|
+
verdict: 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
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// A project file shown as its own annotatable surface: the file's lines render through the diff
|
|
2
|
+
// sheet as context rows (one line-number gutter, no +/- sign), so selecting text leaves a comment
|
|
3
|
+
// anchored to the file exactly as a plan or diff comment is. Used where a review session exists to
|
|
4
|
+
// hold the notes; the bare-launch welcome shell uses the read-only FileContentsView instead.
|
|
5
|
+
|
|
6
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { makeAnchor, annotationTarget, type Anchor, type Thread } from "@cueloop/schema";
|
|
8
|
+
import type { Theme } from "../theme";
|
|
9
|
+
import type { QuickAction } from "../config";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
import { DiffContentView } from "./DiffContentView";
|
|
12
|
+
import { diffRowBlocks, fileContentsRows, marksByRows } from "../view-diff";
|
|
13
|
+
|
|
14
|
+
export interface AnnotatableFileViewProps {
|
|
15
|
+
path: string;
|
|
16
|
+
loadContents: (path: string) => Promise<string | null>;
|
|
17
|
+
session: Thread;
|
|
18
|
+
quickActions: QuickAction[];
|
|
19
|
+
observer: boolean;
|
|
20
|
+
resolved?: boolean;
|
|
21
|
+
suspended?: boolean;
|
|
22
|
+
focusedAnnotationId?: string;
|
|
23
|
+
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
24
|
+
onComposingChange?: (composing: boolean) => void;
|
|
25
|
+
onCursorChange?: (rowIndex: number) => void;
|
|
26
|
+
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
27
|
+
/** Persist a comment on this file; the view built the anchor against the file's own lines. */
|
|
28
|
+
onAddComment: (anchor: Anchor, body: string) => void;
|
|
29
|
+
onReply: (rootAnnotationId: string, body: string) => void;
|
|
30
|
+
onUpdateAnnotation: (id: string, body: string) => void;
|
|
31
|
+
onExit: () => void;
|
|
32
|
+
theme?: Theme;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A finished read for a specific path; `lines` is null when the file could not be read. */
|
|
36
|
+
interface FileLoad {
|
|
37
|
+
path: string;
|
|
38
|
+
lines: string[] | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function AnnotatableFileView(props: AnnotatableFileViewProps): React.ReactNode {
|
|
42
|
+
const tokens = useComponentTheme(props.theme);
|
|
43
|
+
const [loaded, setLoaded] = useState<FileLoad | null>(null);
|
|
44
|
+
const loadRef = useRef(props.loadContents);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
loadRef.current = props.loadContents;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let alive = true;
|
|
51
|
+
void loadRef.current(props.path).then(
|
|
52
|
+
(contents) => {
|
|
53
|
+
if (alive)
|
|
54
|
+
setLoaded({ path: props.path, lines: contents === null ? null : contents.split("\n") });
|
|
55
|
+
},
|
|
56
|
+
() => {
|
|
57
|
+
if (alive) setLoaded({ path: props.path, lines: null });
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
alive = false;
|
|
63
|
+
};
|
|
64
|
+
}, [props.path]);
|
|
65
|
+
|
|
66
|
+
if (loaded === null || loaded.path !== props.path) {
|
|
67
|
+
return (
|
|
68
|
+
<box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
|
|
69
|
+
<text fg={tokens.textDim}>loading...</text>
|
|
70
|
+
</box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (loaded.lines === null) {
|
|
74
|
+
return (
|
|
75
|
+
<box
|
|
76
|
+
style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
|
|
77
|
+
>
|
|
78
|
+
<text fg={tokens.textDim}>File deleted</text>
|
|
79
|
+
</box>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rows = fileContentsRows(props.path, loaded.lines.join("\n"));
|
|
84
|
+
// the file's worktree notes: these rows are the current file, so a head-side (deleted-line) note
|
|
85
|
+
// must stay orphaned here rather than rebind to similar surviving text
|
|
86
|
+
const fileNotes = props.session.annotations.filter((annotation) => {
|
|
87
|
+
const target = annotationTarget(annotation);
|
|
88
|
+
|
|
89
|
+
return target.kind === "file" && target.path === props.path && target.rev === "worktree";
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<DiffContentView
|
|
94
|
+
rows={rows}
|
|
95
|
+
session={props.session}
|
|
96
|
+
marks={marksByRows(fileNotes, rows, props.focusedAnnotationId)}
|
|
97
|
+
quickActions={props.quickActions}
|
|
98
|
+
observer={props.observer}
|
|
99
|
+
commentsEnabled
|
|
100
|
+
resolved={props.resolved}
|
|
101
|
+
suspended={props.suspended}
|
|
102
|
+
fileView
|
|
103
|
+
onComposingChange={props.onComposingChange}
|
|
104
|
+
onObserverBlocked={props.onObserverBlocked}
|
|
105
|
+
onCursorChange={props.onCursorChange}
|
|
106
|
+
focusedAnnotationId={props.focusedAnnotationId}
|
|
107
|
+
onFocusAnnotation={props.onFocusAnnotation}
|
|
108
|
+
onAnnotate={(span, body) =>
|
|
109
|
+
props.onAddComment(
|
|
110
|
+
makeAnchor(
|
|
111
|
+
diffRowBlocks(rows),
|
|
112
|
+
span.start.blockIndex,
|
|
113
|
+
span.start.char,
|
|
114
|
+
span.end.char,
|
|
115
|
+
span.end.blockIndex,
|
|
116
|
+
),
|
|
117
|
+
body,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
onReply={props.onReply}
|
|
121
|
+
onUpdateAnnotation={props.onUpdateAnnotation}
|
|
122
|
+
onExit={props.onExit}
|
|
123
|
+
theme={props.theme}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Annotation } from "@cueloop/schema";
|
|
3
|
+
import { DARK } from "../theme";
|
|
4
|
+
import { annotationPaletteFor } from "../annotation-palette";
|
|
5
|
+
import { slashItemsFrom } from "../slash-palette";
|
|
6
|
+
import { DEFAULT_QUICK_ACTIONS } from "../config";
|
|
7
|
+
import type { Story, StoryMeta } from "./story";
|
|
8
|
+
import { CommentRow, ComposerPalette, DiscussionCard } from "./AnnotationCards";
|
|
9
|
+
|
|
10
|
+
export const meta: StoryMeta = { title: "Chrome/AnnotationCards" };
|
|
11
|
+
|
|
12
|
+
const palette = annotationPaletteFor(DARK);
|
|
13
|
+
|
|
14
|
+
function annotation(id: string, body: string, author?: string): Annotation {
|
|
15
|
+
const comment: Annotation = {
|
|
16
|
+
id,
|
|
17
|
+
kind: "comment",
|
|
18
|
+
anchor: { quote: "the daemon", prefix: "", suffix: "" },
|
|
19
|
+
body,
|
|
20
|
+
createdAt: "2026-09-07T10:00:00.000Z",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (author !== undefined) comment.author = author;
|
|
24
|
+
|
|
25
|
+
return comment;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A two-voice discussion: the owner's comment then a collaborator's reply, each
|
|
29
|
+
// segment wearing its own accent edge.
|
|
30
|
+
export const TwoVoices: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<DiscussionCard
|
|
33
|
+
tokens={DARK}
|
|
34
|
+
segments={[
|
|
35
|
+
{
|
|
36
|
+
color: palette.cardEdge,
|
|
37
|
+
node: <CommentRow annotation={annotation("a", "Which daemon?")} tokens={DARK} />,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
color: DARK.text,
|
|
41
|
+
node: (
|
|
42
|
+
<CommentRow
|
|
43
|
+
annotation={annotation("b", "The one the CLI spawns.", "fp:collab")}
|
|
44
|
+
tokens={DARK}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
),
|
|
51
|
+
expectedColors: [palette.cardEdge, DARK.text],
|
|
52
|
+
size: { width: 60, height: 8 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const FocusedCard: Story = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<DiscussionCard
|
|
58
|
+
focused
|
|
59
|
+
tokens={DARK}
|
|
60
|
+
segments={[
|
|
61
|
+
{
|
|
62
|
+
color: palette.cardEdge,
|
|
63
|
+
node: (
|
|
64
|
+
<CommentRow annotation={annotation("a", "Needs an eviction story.")} tokens={DARK} />
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
]}
|
|
68
|
+
/>
|
|
69
|
+
),
|
|
70
|
+
expectedColors: [DARK.text],
|
|
71
|
+
size: { width: 60, height: 6 },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// The palette a leading "/" opens under the composer, with the second item selected.
|
|
75
|
+
export const SlashPaletteOpen: Story = {
|
|
76
|
+
render: () => (
|
|
77
|
+
<ComposerPalette
|
|
78
|
+
slashActive
|
|
79
|
+
slashItems={slashItemsFrom(DEFAULT_QUICK_ACTIONS)}
|
|
80
|
+
slashIndex={1}
|
|
81
|
+
inline={null}
|
|
82
|
+
tokens={DARK}
|
|
83
|
+
/>
|
|
84
|
+
),
|
|
85
|
+
expectedColors: [DARK.accent, DARK.textDim],
|
|
86
|
+
size: { width: 90, height: 8 },
|
|
87
|
+
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline comment pieces every annotated surface renders: the composer (a
|
|
3
|
+
* textarea in a card row), the "/" palette and inline completion hint below it,
|
|
4
|
+
* the edge-segmented discussion card, and one comment row. The plan thread view
|
|
5
|
+
* and the diff sheet both draw their comments with these, so a discussion looks
|
|
6
|
+
* and behaves the same on prose and on code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
10
|
+
import type { BoxRenderable, KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
11
|
+
import type { Annotation } from "@cueloop/schema";
|
|
12
|
+
import type { Theme } from "../theme";
|
|
13
|
+
import { lighten } from "../annotation-palette";
|
|
14
|
+
import { useFrameMeasure } from "../use-frame-measure";
|
|
15
|
+
import type { InlineSlash, SlashItem } from "../slash-palette";
|
|
16
|
+
|
|
17
|
+
/* -------------------------------------------------------------- composer */
|
|
18
|
+
|
|
19
|
+
// cmd+enter sends (super under the kitty protocol, meta where cmd arrives
|
|
20
|
+
// ESC-prefixed, ctrl as the fallback where the terminal itself consumes
|
|
21
|
+
// cmd+enter); plain enter breaks the line
|
|
22
|
+
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
23
|
+
{ name: "return", super: true, action: "submit" },
|
|
24
|
+
{ name: "return", meta: true, action: "submit" },
|
|
25
|
+
{ name: "return", ctrl: true, action: "submit" },
|
|
26
|
+
{ name: "return", action: "newline" },
|
|
27
|
+
{ name: "return", shift: true, action: "newline" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function composeRowCount(text: string, contentWidth: number): number {
|
|
31
|
+
const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
|
|
32
|
+
let visualRowCount = 0;
|
|
33
|
+
|
|
34
|
+
for (const line of text.split("\n")) {
|
|
35
|
+
visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// no cap: the card is the discussion, and folding is its containment
|
|
39
|
+
return Math.max(1, visualRowCount);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Composer({
|
|
43
|
+
seed,
|
|
44
|
+
glyph,
|
|
45
|
+
tokens,
|
|
46
|
+
onSave,
|
|
47
|
+
onReady,
|
|
48
|
+
onInput,
|
|
49
|
+
}: {
|
|
50
|
+
seed: string;
|
|
51
|
+
glyph: string;
|
|
52
|
+
tokens: Theme;
|
|
53
|
+
onSave: (body: string) => void;
|
|
54
|
+
onReady: () => void;
|
|
55
|
+
onInput: (text: string) => void;
|
|
56
|
+
}): React.ReactNode {
|
|
57
|
+
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
58
|
+
const [rows, setRows] = useState(1);
|
|
59
|
+
|
|
60
|
+
// once per mount (the composer is keyed by its seed): later re-renders
|
|
61
|
+
// must NOT reset the caret, or input lands before a just-typed newline
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const editor = editorRef.current;
|
|
64
|
+
|
|
65
|
+
if (!editor) return;
|
|
66
|
+
editor.cursorOffset = seed.length;
|
|
67
|
+
setRows(composeRowCount(editor.plainText, editor.width));
|
|
68
|
+
onReady();
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<box style={{ flexDirection: "row" }}>
|
|
74
|
+
<text selectable={false} fg={tokens.text} style={{ flexShrink: 0 }}>{`${glyph} `}</text>
|
|
75
|
+
<textarea
|
|
76
|
+
ref={editorRef}
|
|
77
|
+
focused
|
|
78
|
+
initialValue={seed}
|
|
79
|
+
cursorStyle={{ style: "block", blinking: true }}
|
|
80
|
+
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
81
|
+
onSubmit={() => onSave(editorRef.current?.plainText ?? "")}
|
|
82
|
+
onContentChange={() => {
|
|
83
|
+
const editor = editorRef.current;
|
|
84
|
+
|
|
85
|
+
if (!editor) return;
|
|
86
|
+
setRows(composeRowCount(editor.plainText, editor.width));
|
|
87
|
+
onInput(editor.plainText);
|
|
88
|
+
}}
|
|
89
|
+
style={{
|
|
90
|
+
height: rows,
|
|
91
|
+
flexGrow: 1,
|
|
92
|
+
backgroundColor: "transparent",
|
|
93
|
+
focusedBackgroundColor: "transparent",
|
|
94
|
+
textColor: tokens.text,
|
|
95
|
+
focusedTextColor: tokens.text,
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
</box>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ---------------------------------------------------------- slash palette */
|
|
103
|
+
|
|
104
|
+
function SlashList({
|
|
105
|
+
items,
|
|
106
|
+
selected,
|
|
107
|
+
tokens,
|
|
108
|
+
}: {
|
|
109
|
+
items: SlashItem[];
|
|
110
|
+
selected: number;
|
|
111
|
+
tokens: Theme;
|
|
112
|
+
}): React.ReactNode {
|
|
113
|
+
const WINDOW = 5;
|
|
114
|
+
const start = Math.max(0, Math.min(selected - 2, items.length - WINDOW));
|
|
115
|
+
const visible = items.slice(start, start + WINDOW);
|
|
116
|
+
|
|
117
|
+
if (items.length === 0) return <text fg={tokens.textDim}>no matching actions</text>;
|
|
118
|
+
|
|
119
|
+
const nameColumn = Math.min(36, Math.max(16, ...visible.map((item) => item.name.length + 5)));
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<box style={{ flexDirection: "column" }}>
|
|
123
|
+
{visible.map((item, offset) => {
|
|
124
|
+
const index = start + offset;
|
|
125
|
+
const isSelected = index === selected;
|
|
126
|
+
const name = `${isSelected ? "→ " : " "}/${item.name}`.padEnd(nameColumn);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<text key={item.name} style={{ flexShrink: 0, wrapMode: "none" }}>
|
|
130
|
+
<span fg={isSelected ? tokens.accent : tokens.textMuted}>{name}</span>
|
|
131
|
+
<span fg={tokens.textDim}>{item.description}</span>
|
|
132
|
+
</text>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
{items.length > WINDOW ? (
|
|
136
|
+
<text fg={tokens.textDim}>{`(${selected + 1}/${items.length})`}</text>
|
|
137
|
+
) : null}
|
|
138
|
+
</box>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Below the composer: the palette list for a leading "/", else the inline tab-hint, else nothing. */
|
|
143
|
+
export function ComposerPalette({
|
|
144
|
+
slashActive,
|
|
145
|
+
slashItems,
|
|
146
|
+
slashIndex,
|
|
147
|
+
inline,
|
|
148
|
+
tokens,
|
|
149
|
+
}: {
|
|
150
|
+
slashActive: boolean;
|
|
151
|
+
slashItems: SlashItem[];
|
|
152
|
+
slashIndex: number;
|
|
153
|
+
inline: InlineSlash | null;
|
|
154
|
+
tokens: Theme;
|
|
155
|
+
}): React.ReactNode {
|
|
156
|
+
if (slashActive) {
|
|
157
|
+
return (
|
|
158
|
+
<box style={{ flexDirection: "column", marginLeft: 3 }}>
|
|
159
|
+
<SlashList
|
|
160
|
+
items={slashItems}
|
|
161
|
+
selected={Math.min(slashIndex, Math.max(0, slashItems.length - 1))}
|
|
162
|
+
tokens={tokens}
|
|
163
|
+
/>
|
|
164
|
+
</box>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (inline === null) return null;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<box style={{ flexDirection: "row", marginLeft: 3 }}>
|
|
171
|
+
<text>
|
|
172
|
+
<span fg={tokens.textDim}>{"⇥ "}</span>
|
|
173
|
+
<span fg={tokens.accent}>{`/${inline.suggestion.name}`}</span>
|
|
174
|
+
<span fg={tokens.textDim}>{` ${inline.suggestion.description}`}</span>
|
|
175
|
+
</text>
|
|
176
|
+
</box>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------ discussion card */
|
|
181
|
+
|
|
182
|
+
/** One card row group with its own accent-edge color, one per voice. */
|
|
183
|
+
export interface EdgeSegment {
|
|
184
|
+
color: string;
|
|
185
|
+
node: React.ReactNode;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The comment-card frame. The accent edge is drawn by hand so that (a) its
|
|
190
|
+
* corner cells are half-height glyphs ("╷"/"╵") terminating flush with the
|
|
191
|
+
* hairline rows, and (b) each voice's rows carry their own edge color with
|
|
192
|
+
* zero gap. Focus lightens every segment; the hairlines never change.
|
|
193
|
+
*/
|
|
194
|
+
export function DiscussionCard({
|
|
195
|
+
segments,
|
|
196
|
+
tokens,
|
|
197
|
+
focused = false,
|
|
198
|
+
onFocus,
|
|
199
|
+
}: {
|
|
200
|
+
segments: EdgeSegment[];
|
|
201
|
+
tokens: Theme;
|
|
202
|
+
focused?: boolean;
|
|
203
|
+
onFocus?: () => void;
|
|
204
|
+
}): React.ReactNode {
|
|
205
|
+
const segmentRefs = useRef<Array<BoxRenderable | null>>([]);
|
|
206
|
+
const noHeights: number[] = [];
|
|
207
|
+
const heights = useFrameMeasure(
|
|
208
|
+
() => segmentRefs.current.map((renderable) => renderable?.height ?? 0),
|
|
209
|
+
(left, right) => left.join() === right.join(),
|
|
210
|
+
noHeights,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const edgeColor = (color: string): string => (focused ? lighten(color) : color);
|
|
214
|
+
const glyphs = focused
|
|
215
|
+
? { top: "╻", middle: "┃", bottom: "╹" }
|
|
216
|
+
: { top: "╷", middle: "│", bottom: "╵" };
|
|
217
|
+
const edgeRows: Array<{ glyph: string; color: string }> = [
|
|
218
|
+
{
|
|
219
|
+
glyph: glyphs.top,
|
|
220
|
+
color: edgeColor(segments[0]?.color ?? tokens.border),
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
for (let index = 0; index < segments.length; index++) {
|
|
225
|
+
const segmentRows = Math.max(1, heights[index] ?? 1);
|
|
226
|
+
|
|
227
|
+
for (let row = 0; row < segmentRows; row++) {
|
|
228
|
+
edgeRows.push({
|
|
229
|
+
glyph: glyphs.middle,
|
|
230
|
+
color: edgeColor(segments[index]!.color),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
edgeRows.push({
|
|
235
|
+
glyph: glyphs.bottom,
|
|
236
|
+
color: edgeColor(segments[segments.length - 1]?.color ?? tokens.border),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<box style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
|
|
241
|
+
<text selectable={false} style={{ flexShrink: 0, width: 1 }}>
|
|
242
|
+
{edgeRows.map((row, index) => (
|
|
243
|
+
<span key={index} fg={row.color}>
|
|
244
|
+
{index < edgeRows.length - 1 ? `${row.glyph}\n` : row.glyph}
|
|
245
|
+
</span>
|
|
246
|
+
))}
|
|
247
|
+
</text>
|
|
248
|
+
<box
|
|
249
|
+
onMouseDown={onFocus}
|
|
250
|
+
style={{
|
|
251
|
+
flexGrow: 1,
|
|
252
|
+
flexDirection: "column",
|
|
253
|
+
border: ["top", "right", "bottom"],
|
|
254
|
+
borderStyle: "single",
|
|
255
|
+
borderColor: tokens.border,
|
|
256
|
+
backgroundColor: "transparent",
|
|
257
|
+
paddingLeft: 1,
|
|
258
|
+
paddingRight: 1,
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{segments.map((segment, index) => (
|
|
262
|
+
<box
|
|
263
|
+
key={index}
|
|
264
|
+
ref={(renderable: BoxRenderable | null) => {
|
|
265
|
+
segmentRefs.current[index] = renderable;
|
|
266
|
+
}}
|
|
267
|
+
style={{ flexDirection: "column" }}
|
|
268
|
+
>
|
|
269
|
+
{segment.node}
|
|
270
|
+
</box>
|
|
271
|
+
))}
|
|
272
|
+
</box>
|
|
273
|
+
</box>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function CommentRow({
|
|
278
|
+
annotation,
|
|
279
|
+
tokens,
|
|
280
|
+
}: {
|
|
281
|
+
annotation: Annotation;
|
|
282
|
+
tokens: Theme;
|
|
283
|
+
}): React.ReactNode {
|
|
284
|
+
// own comments (no author) wear the filled dot, collaborators the outline
|
|
285
|
+
const own = annotation.author === undefined;
|
|
286
|
+
const glyph = own ? "●" : "○";
|
|
287
|
+
const glyphColor = own ? tokens.text : tokens.textMuted;
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<box style={{ flexDirection: "column" }}>
|
|
291
|
+
{annotation.body.split("\n").map((line, lineIndex) => (
|
|
292
|
+
<box key={lineIndex} style={{ flexDirection: "row" }}>
|
|
293
|
+
<text selectable={false} fg={glyphColor} style={{ flexShrink: 0 }}>
|
|
294
|
+
{lineIndex === 0 ? `${glyph} ` : " "}
|
|
295
|
+
</text>
|
|
296
|
+
<text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
|
|
297
|
+
{line}
|
|
298
|
+
</text>
|
|
299
|
+
</box>
|
|
300
|
+
))}
|
|
301
|
+
</box>
|
|
302
|
+
);
|
|
303
|
+
}
|