@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79
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/package.json +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- 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/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/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- 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/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
|
@@ -42,6 +42,9 @@ export interface ThreadTreeProps {
|
|
|
42
42
|
|
|
43
43
|
const MENU_WIDTH = 12;
|
|
44
44
|
|
|
45
|
+
/** Columns per nesting level in the sidebar cascade: section → project → thread. */
|
|
46
|
+
const SIDEBAR_INDENT = 2;
|
|
47
|
+
|
|
45
48
|
function ActionsMenu({
|
|
46
49
|
pinned,
|
|
47
50
|
onPin,
|
|
@@ -76,7 +79,7 @@ function ActionsMenu({
|
|
|
76
79
|
>
|
|
77
80
|
{item(pinned ? "unstar" : "star", tokens.text, onPin)}
|
|
78
81
|
{item("rename", tokens.text, onRename)}
|
|
79
|
-
{item("delete", tokens.
|
|
82
|
+
{item("delete", tokens.text, onDelete)}
|
|
80
83
|
</box>
|
|
81
84
|
);
|
|
82
85
|
}
|
|
@@ -85,6 +88,7 @@ interface ThreadRowProps {
|
|
|
85
88
|
title: string;
|
|
86
89
|
selected: boolean;
|
|
87
90
|
pinned: boolean;
|
|
91
|
+
depth: number;
|
|
88
92
|
titleWidth: number;
|
|
89
93
|
menuOpen: boolean;
|
|
90
94
|
onToggleMenu: () => void;
|
|
@@ -97,7 +101,8 @@ interface ThreadRowProps {
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
function ThreadRow(props: ThreadRowProps): React.ReactNode {
|
|
100
|
-
const { title, selected, pinned, titleWidth, menuOpen, onToggleMenu, tokens, theme } =
|
|
104
|
+
const { title, selected, pinned, depth, titleWidth, menuOpen, onToggleMenu, tokens, theme } =
|
|
105
|
+
props;
|
|
101
106
|
const [hovered, setHovered] = useState(false);
|
|
102
107
|
const rowRef = useRef<BoxRenderable | null>(null);
|
|
103
108
|
const { setOverlay, clearOverlay } = useRootOverlay();
|
|
@@ -157,8 +162,8 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
|
|
|
157
162
|
backgroundColor: selected ? tokens.elevated : hovered ? tokens.panel : undefined,
|
|
158
163
|
}}
|
|
159
164
|
>
|
|
160
|
-
<
|
|
161
|
-
|
|
165
|
+
<box style={{ width: depth * SIDEBAR_INDENT, flexShrink: 0 }} />
|
|
166
|
+
<text wrapMode="none" style={{ flexShrink: 1 }}>
|
|
162
167
|
<span fg={selected ? tokens.accent : tokens.textMuted}>{clippedTitle}</span>
|
|
163
168
|
</text>
|
|
164
169
|
<box style={{ flexGrow: 1 }} />
|
|
@@ -212,8 +217,7 @@ export function ThreadTree({
|
|
|
212
217
|
|
|
213
218
|
if (row.kind === "project") {
|
|
214
219
|
return (
|
|
215
|
-
<text key={row.id}>
|
|
216
|
-
<span fg={tokens.blue}>{` ${NERD.folderOpen} `}</span>
|
|
220
|
+
<text key={row.id} wrapMode="none" style={{ marginLeft: SIDEBAR_INDENT }}>
|
|
217
221
|
<span fg={tokens.textMuted}>{row.label}</span>
|
|
218
222
|
</text>
|
|
219
223
|
);
|
|
@@ -230,7 +234,8 @@ export function ThreadTree({
|
|
|
230
234
|
focused || activeId === undefined ? row.selectionIndex === cursor : activeId === id
|
|
231
235
|
}
|
|
232
236
|
pinned={pinnedIds?.has(id) ?? false}
|
|
233
|
-
|
|
237
|
+
depth={row.depth}
|
|
238
|
+
titleWidth={Math.max(8, titleWidth - (row.depth - 1) * SIDEBAR_INDENT)}
|
|
234
239
|
menuOpen={menuControl.openMenuId === `thread:${id}`}
|
|
235
240
|
onToggleMenu={() => menuControl.toggleMenu(`thread:${id}`)}
|
|
236
241
|
onSelect={onSelect ? () => onSelect(id) : undefined}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* all asserted on the frame, not on internal state.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test, type Mock } from "bun:test";
|
|
11
11
|
import React from "react";
|
|
12
12
|
import { testRender } from "@opentui/react/test-utils";
|
|
13
13
|
import { TextAttributes } from "@opentui/core";
|
|
@@ -17,6 +17,7 @@ import { settle, typeText } from "../test-support";
|
|
|
17
17
|
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
18
18
|
import { makeAnchor, parseBlocks, type Annotation } from "@cueloop/schema";
|
|
19
19
|
import type { TextSpan } from "../thread-selection";
|
|
20
|
+
import * as markRuns from "../mark-runs";
|
|
20
21
|
import { fixturePlanSession } from "./story-fixtures";
|
|
21
22
|
import { ThreadView } from "./ThreadView";
|
|
22
23
|
|
|
@@ -810,3 +811,89 @@ describe("discussion identity", () => {
|
|
|
810
811
|
expect(annotate).not.toHaveBeenCalled();
|
|
811
812
|
});
|
|
812
813
|
});
|
|
814
|
+
|
|
815
|
+
describe("a new comment sorts into place while typing", () => {
|
|
816
|
+
const blocks = parseBlocks(PLAN);
|
|
817
|
+
const anchorOn = (word: string): Annotation["anchor"] =>
|
|
818
|
+
makeAnchor(blocks, 1, PARAGRAPH.indexOf(word), PARAGRAPH.indexOf(word) + word.length);
|
|
819
|
+
const early: Annotation = {
|
|
820
|
+
id: "early",
|
|
821
|
+
kind: "comment",
|
|
822
|
+
anchor: anchorOn("persists"),
|
|
823
|
+
body: "alpha-note",
|
|
824
|
+
createdAt: "2026-09-01T10:00:00Z",
|
|
825
|
+
};
|
|
826
|
+
const late: Annotation = {
|
|
827
|
+
id: "late",
|
|
828
|
+
kind: "comment",
|
|
829
|
+
anchor: anchorOn("atomically"),
|
|
830
|
+
body: "omega-note",
|
|
831
|
+
createdAt: "2026-09-01T10:01:00Z",
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
test("a draft anchored between two comments renders between them, not appended to the stack", async () => {
|
|
835
|
+
// Arrange - two comments on the same line, one earlier and one later than the draft's word
|
|
836
|
+
setup.renderer.destroy();
|
|
837
|
+
await mount([early, late]);
|
|
838
|
+
|
|
839
|
+
// Act - mark a word between them and start typing a new comment
|
|
840
|
+
const sessions = locate("sessions");
|
|
841
|
+
|
|
842
|
+
await setup.mockMouse.doubleClick(sessions.column + 2, sessions.row);
|
|
843
|
+
await settle(setup);
|
|
844
|
+
await typeText(setup, "midway-note");
|
|
845
|
+
await settle(setup);
|
|
846
|
+
|
|
847
|
+
// Assert - the draft sits where it will land on save (by span start), not at the end
|
|
848
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
849
|
+
const rowOf = (needle: string): number => rows.findIndex((row) => row.includes(needle));
|
|
850
|
+
|
|
851
|
+
expect(rowOf("alpha-note")).toBeGreaterThanOrEqual(0);
|
|
852
|
+
expect(rowOf("midway-note")).toBeGreaterThan(rowOf("alpha-note"));
|
|
853
|
+
expect(rowOf("omega-note")).toBeGreaterThan(rowOf("midway-note"));
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
describe("virtualization guard", () => {
|
|
858
|
+
// The performance guard: the thread view must word-wrap only the blocks in the viewport, not the
|
|
859
|
+
// whole document. Deterministic (a call count, not a wall-clock), so it fails on any machine if a
|
|
860
|
+
// future change un-virtualizes the view - the regression the nav-latency benchmark would show.
|
|
861
|
+
test("a large plan word-wraps only the on-screen blocks, not every block", async () => {
|
|
862
|
+
// Arrange - a plan far taller than the viewport
|
|
863
|
+
const paragraphs = Array.from({ length: 800 }, (_, index) => `Paragraph ${index} with words.`);
|
|
864
|
+
const bigPlan = `# Big Plan\n\n${paragraphs.join("\n\n")}\n`;
|
|
865
|
+
const display = buildDisplay(bigPlan, undefined);
|
|
866
|
+
const bigSession = fixturePlanSession({
|
|
867
|
+
artifact: { type: "plan", content: bigPlan, meta: { title: "Big", planPath: "big.md" } },
|
|
868
|
+
annotations: [],
|
|
869
|
+
});
|
|
870
|
+
const spy = spyOn(markRuns, "wrapLines");
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
// Act
|
|
874
|
+
const bigSetup = await testRender(
|
|
875
|
+
<ThreadView
|
|
876
|
+
session={bigSession}
|
|
877
|
+
display={display}
|
|
878
|
+
marks={new Map()}
|
|
879
|
+
quickActions={DEFAULT_QUICK_ACTIONS}
|
|
880
|
+
observer={false}
|
|
881
|
+
onAnnotate={() => {}}
|
|
882
|
+
onReply={() => {}}
|
|
883
|
+
onUpdateAnnotation={() => {}}
|
|
884
|
+
onExit={() => {}}
|
|
885
|
+
/>,
|
|
886
|
+
{ width: 64, height: 24 },
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
await settle(bigSetup);
|
|
890
|
+
|
|
891
|
+
// Assert - across the mount's renders the view never wraps a block for each of the 800; a
|
|
892
|
+
// viewport-worth per render stays far under one full pass over the document
|
|
893
|
+
expect(spy.mock.calls.length).toBeLessThan(display.length);
|
|
894
|
+
bigSetup.renderer.destroy();
|
|
895
|
+
} finally {
|
|
896
|
+
spy.mockRestore();
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
});
|
|
@@ -60,42 +60,6 @@ async function mountComposerWithSkills(skills: SlashItem[] = [], width = 72) {
|
|
|
60
60
|
|
|
61
61
|
const mountComposer = () => mountComposerWithSkills();
|
|
62
62
|
|
|
63
|
-
describe("the command leader", () => {
|
|
64
|
-
test("the leader chord then a letter runs a command instead of composing a comment", async () => {
|
|
65
|
-
const commands: string[] = [];
|
|
66
|
-
const display = buildDisplay(PLAN, undefined);
|
|
67
|
-
const setup = await testRender(
|
|
68
|
-
<ThreadView
|
|
69
|
-
session={fixturePlanSession({
|
|
70
|
-
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
71
|
-
})}
|
|
72
|
-
display={display}
|
|
73
|
-
marks={marksByDisplay([], display)}
|
|
74
|
-
quickActions={[]}
|
|
75
|
-
observer={false}
|
|
76
|
-
leaderCombos={["ctrl+g"]}
|
|
77
|
-
onLeaderCommand={(key) => commands.push(key.name)}
|
|
78
|
-
onAnnotate={() => {}}
|
|
79
|
-
onReply={() => {}}
|
|
80
|
-
onUpdateAnnotation={() => {}}
|
|
81
|
-
onExit={() => {}}
|
|
82
|
-
/>,
|
|
83
|
-
{ width: 72, height: 20 },
|
|
84
|
-
);
|
|
85
|
-
await settle(setup);
|
|
86
|
-
|
|
87
|
-
setup.mockInput.pressKey("g", { ctrl: true });
|
|
88
|
-
setup.mockInput.pressKey("x");
|
|
89
|
-
await settle(setup);
|
|
90
|
-
|
|
91
|
-
// the command fired and no composer opened (a bare "x" would have started a draft)
|
|
92
|
-
expect(commands).toEqual(["x"]);
|
|
93
|
-
expect(setup.captureCharFrame()).not.toContain("enter save");
|
|
94
|
-
|
|
95
|
-
setup.renderer.destroy();
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
63
|
describe("skills in the palette", () => {
|
|
100
64
|
test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
|
|
101
65
|
const setup = await mountComposerWithSkills([
|
|
@@ -13,32 +13,48 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import React, { useEffect, useRef } from "react";
|
|
16
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
16
17
|
import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
|
|
17
18
|
import type { Annotation, Thread } from "@cueloop/schema";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
displayText,
|
|
21
|
+
renderedText,
|
|
22
|
+
renderedStyleRuns,
|
|
23
|
+
styledRunsFor,
|
|
24
|
+
type DisplayBlock,
|
|
25
|
+
type Mark,
|
|
26
|
+
type RenderedRun,
|
|
27
|
+
type RunRole,
|
|
28
|
+
type StyledRun,
|
|
29
|
+
} from "../view-plan";
|
|
19
30
|
import type { TextSpan } from "../thread-selection";
|
|
20
31
|
import type { QuickAction } from "../config";
|
|
21
32
|
import type { CheatsheetSection } from "../key-bindings";
|
|
22
33
|
import type { Theme } from "../theme";
|
|
23
|
-
import { BOLD, CUT, UNDERLINE } from "../annotation-palette";
|
|
24
|
-
import {
|
|
34
|
+
import { BOLD, CUT, ITALIC, UNDERLINE } from "../annotation-palette";
|
|
35
|
+
import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
|
|
36
|
+
import { MarkdownGridBlock } from "./MarkdownGridBlock";
|
|
25
37
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
38
|
+
import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
26
39
|
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
40
|
+
import { NavModeHint } from "./NavModeHint";
|
|
27
41
|
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
28
42
|
import { useComponentTheme } from "./theme-context";
|
|
29
43
|
|
|
44
|
+
/** Blocks kept mounted beyond the viewport, so a scroll step or a drag past the edge never shows a gap. */
|
|
45
|
+
const OVERSCAN_BLOCKS = 8;
|
|
46
|
+
|
|
30
47
|
export { lighten } from "../annotation-palette";
|
|
31
48
|
|
|
32
49
|
/**
|
|
33
|
-
* How a block's rows are painted: heading weight, muted kinds, the list or
|
|
34
|
-
* quote marker
|
|
35
|
-
*
|
|
50
|
+
* How a block's rows are painted: heading weight, muted kinds, and the list or
|
|
51
|
+
* quote marker. A cut block reads dim and struck through; other edits are
|
|
52
|
+
* tracked into the working copy but not tagged in the read-only view.
|
|
36
53
|
*/
|
|
37
54
|
interface BlockStyle {
|
|
38
55
|
baseFg: string;
|
|
39
56
|
baseAttributes: number;
|
|
40
57
|
marker: string;
|
|
41
|
-
changeTag: { text: string; fg: string } | null;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
|
|
@@ -53,27 +69,42 @@ function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
|
|
|
53
69
|
: block.kind === "quote"
|
|
54
70
|
? "▏ "
|
|
55
71
|
: "";
|
|
56
|
-
const changeTag =
|
|
57
|
-
block.type === "add"
|
|
58
|
-
? { text: " [new]", fg: tokens.green }
|
|
59
|
-
: block.type === "mod"
|
|
60
|
-
? { text: " [edited]", fg: tokens.accent }
|
|
61
|
-
: null;
|
|
62
72
|
|
|
63
73
|
return {
|
|
64
74
|
baseFg: isCut ? tokens.textDim : muted ? tokens.textMuted : tokens.text,
|
|
65
75
|
baseAttributes: (isHeading ? BOLD : 0) | (isCut ? CUT : 0),
|
|
66
76
|
marker,
|
|
67
|
-
changeTag,
|
|
68
77
|
};
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
/** The read-only color of an inline markdown role: links accent, code and removals dim, insertions green. */
|
|
81
|
+
function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
|
|
82
|
+
if (role === "link") return tokens.blue;
|
|
83
|
+
if (role === "ins") return tokens.green;
|
|
84
|
+
if (role === "code" || role === "del") return tokens.textDim;
|
|
85
|
+
|
|
86
|
+
return baseFg;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The text attributes an inline role adds over a block's base attributes. */
|
|
90
|
+
function roleAttributes(role: RunRole, baseAttributes: number): number {
|
|
91
|
+
if (role === "strong") return baseAttributes | BOLD;
|
|
92
|
+
if (role === "em") return baseAttributes | ITALIC;
|
|
93
|
+
if (role === "strike" || role === "del") return baseAttributes | CUT;
|
|
94
|
+
|
|
95
|
+
return baseAttributes;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isGridKind(kind: DisplayBlock["kind"]): boolean {
|
|
99
|
+
return kind === "table" || kind === "frontmatter";
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
/* ------------------------------------------------------------------ view */
|
|
72
103
|
|
|
73
104
|
/** The grammar as the keybinds dialog lists it; the view owns these keys, so they are not rebindable. */
|
|
74
105
|
export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
|
|
75
106
|
{
|
|
76
|
-
title: "Thread",
|
|
107
|
+
title: "Thread (type)",
|
|
77
108
|
entries: [
|
|
78
109
|
{ keys: "click", label: "place the caret" },
|
|
79
110
|
{ keys: "drag", label: "mark text, across blocks" },
|
|
@@ -82,11 +113,9 @@ export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
|
|
|
82
113
|
{ keys: "⇧← / ⇧→", label: "hold a mark" },
|
|
83
114
|
{ keys: "↑ / ↓", label: "move by block" },
|
|
84
115
|
{ keys: "type", label: "comment on the mark" },
|
|
85
|
-
{ keys: "⌘⌥m", label: "comment" },
|
|
86
116
|
{ keys: "enter", label: "reply to the comment" },
|
|
87
|
-
{ keys: "
|
|
88
|
-
{ keys: "
|
|
89
|
-
{ keys: "esc", label: "drop the mark" },
|
|
117
|
+
{ keys: "esc", label: "nav mode" },
|
|
118
|
+
{ keys: "tab", label: "cycle panes" },
|
|
90
119
|
{ keys: "⌃q", label: "quit" },
|
|
91
120
|
],
|
|
92
121
|
},
|
|
@@ -129,8 +158,7 @@ export interface ThreadViewProps {
|
|
|
129
158
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
130
159
|
/** The author's display name for a comment's hover tooltip. */
|
|
131
160
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
132
|
-
|
|
133
|
-
onLeaderCommand?: (key: KeyEvent) => void;
|
|
161
|
+
onNavCommand?: (key: KeyEvent) => boolean;
|
|
134
162
|
onExit: () => void;
|
|
135
163
|
theme?: Theme;
|
|
136
164
|
}
|
|
@@ -153,15 +181,15 @@ export function ThreadView({
|
|
|
153
181
|
onReply,
|
|
154
182
|
onUpdateAnnotation,
|
|
155
183
|
resolveAuthorLabel,
|
|
156
|
-
|
|
157
|
-
onLeaderCommand,
|
|
184
|
+
onNavCommand,
|
|
158
185
|
onExit,
|
|
159
186
|
theme,
|
|
160
187
|
}: ThreadViewProps): React.ReactNode {
|
|
161
188
|
const tokens = useComponentTheme(theme);
|
|
162
189
|
const source: LineSource = {
|
|
163
190
|
count: display.length,
|
|
164
|
-
|
|
191
|
+
// the surface hit-tests and paints in rendered text (inline markers concealed); a grid anchors on its raw source
|
|
192
|
+
textAt: (blockIndex) => renderedText(display[blockIndex]!),
|
|
165
193
|
annotatable: () => true,
|
|
166
194
|
};
|
|
167
195
|
const surface = useAnnotationSurface({
|
|
@@ -182,43 +210,73 @@ export function ThreadView({
|
|
|
182
210
|
onReply,
|
|
183
211
|
onUpdateAnnotation,
|
|
184
212
|
resolveAuthorLabel,
|
|
185
|
-
|
|
186
|
-
onLeaderCommand,
|
|
213
|
+
onNavCommand,
|
|
187
214
|
onExit,
|
|
188
215
|
});
|
|
189
216
|
const { palette, discussions } = surface;
|
|
190
217
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
218
|
+
const { height: terminalHeight } = useTerminalDimensions();
|
|
191
219
|
const viewWidth = useFrameMeasure(
|
|
192
220
|
() => scrollRef.current?.content?.width ?? 0,
|
|
193
221
|
(left, right) => left === right,
|
|
194
222
|
0,
|
|
195
223
|
);
|
|
196
224
|
|
|
225
|
+
// only the viewport's blocks (plus an overscan) mount; a block is estimated at its wrapped-line
|
|
226
|
+
// count until its box, cards included, is measured. The initial viewport seeds the first render so a
|
|
227
|
+
// freshly mounted thread (a switch from a diff, another project) windows its first paint rather than
|
|
228
|
+
// building every block, and never flashes blank.
|
|
229
|
+
const estimateBlockRows = (index: number): number => {
|
|
230
|
+
const block = display[index];
|
|
231
|
+
|
|
232
|
+
if (!block) return 1;
|
|
233
|
+
const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
|
|
234
|
+
const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
|
|
235
|
+
|
|
236
|
+
return lines + (index === 0 ? 0 : 1);
|
|
237
|
+
};
|
|
238
|
+
const virtual = useTerminalVirtualizer({
|
|
239
|
+
scrollbox: scrollRef,
|
|
240
|
+
count: display.length,
|
|
241
|
+
estimateSize: estimateBlockRows,
|
|
242
|
+
overscan: OVERSCAN_BLOCKS,
|
|
243
|
+
});
|
|
244
|
+
|
|
197
245
|
useEffect(() => {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
} catch {
|
|
201
|
-
// best-effort reveal
|
|
202
|
-
}
|
|
246
|
+
virtual.scrollToIndex(surface.revealBlockIndex);
|
|
247
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
248
|
}, [surface.revealBlockIndex]);
|
|
204
249
|
|
|
205
250
|
interface LineContext {
|
|
206
251
|
blockIndex: number;
|
|
207
|
-
|
|
252
|
+
roleRuns: RenderedRun[];
|
|
208
253
|
line: VisualLine;
|
|
209
254
|
lineIndex: number;
|
|
210
255
|
ranges: MarkRange[];
|
|
211
256
|
marker: string;
|
|
212
257
|
baseFg: string;
|
|
213
258
|
baseAttributes: number;
|
|
214
|
-
/** A tag after the row's text, such as the tracked-change label. */
|
|
215
|
-
trailing: { text: string; fg: string } | null;
|
|
216
259
|
}
|
|
217
260
|
|
|
261
|
+
const paintedSpan = (
|
|
262
|
+
run: StyledRun,
|
|
263
|
+
runIndex: number,
|
|
264
|
+
baseFg: string,
|
|
265
|
+
baseAttributes: number,
|
|
266
|
+
) => (
|
|
267
|
+
<span
|
|
268
|
+
key={runIndex}
|
|
269
|
+
fg={roleForeground(run.role, baseFg, tokens)}
|
|
270
|
+
bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
|
|
271
|
+
attributes={roleAttributes(run.role, baseAttributes) | (run.marked ? UNDERLINE : 0)}
|
|
272
|
+
>
|
|
273
|
+
{run.text}
|
|
274
|
+
</span>
|
|
275
|
+
);
|
|
276
|
+
|
|
218
277
|
const lineRowFor = (context: LineContext): React.ReactNode => {
|
|
219
|
-
const { blockIndex,
|
|
278
|
+
const { blockIndex, roleRuns, line, lineIndex, ranges, marker, baseFg, baseAttributes } =
|
|
220
279
|
context;
|
|
221
|
-
const lineRanges = lineMarkRanges(ranges, line);
|
|
222
280
|
|
|
223
281
|
return (
|
|
224
282
|
<box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
|
|
@@ -232,17 +290,25 @@ export function ThreadView({
|
|
|
232
290
|
ref={surface.registerLine(blockIndex, lineIndex, line)}
|
|
233
291
|
onMouseDown={surface.onLineMouseDown}
|
|
234
292
|
>
|
|
235
|
-
{
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
293
|
+
{styledRunsFor(roleRuns, line, ranges).map((run, runIndex) =>
|
|
294
|
+
paintedSpan(run, runIndex, baseFg, baseAttributes),
|
|
295
|
+
)}
|
|
296
|
+
</text>
|
|
297
|
+
</box>
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// an h1 or h2 sits over a dim rule; h3 gets weight only. The row is
|
|
302
|
+
// always present for those kinds (only its width tracks the measured viewport) so a late width
|
|
303
|
+
// measurement never adds a row and shifts the blocks below it out from under a pending click
|
|
304
|
+
const headingRule = (block: DisplayBlock): React.ReactNode => {
|
|
305
|
+
if (block.kind !== "h1" && block.kind !== "h2") return null;
|
|
306
|
+
const width = Math.max(1, (viewWidth > 6 ? viewWidth : 46) - 6);
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<box key="heading-rule" style={{ flexDirection: "row" }}>
|
|
310
|
+
<text selectable={false} fg={tokens.textDim}>
|
|
311
|
+
{" " + "─".repeat(width)}
|
|
246
312
|
</text>
|
|
247
313
|
</box>
|
|
248
314
|
);
|
|
@@ -250,10 +316,54 @@ export function ThreadView({
|
|
|
250
316
|
|
|
251
317
|
const blockNodeFor = (blockIndex: number): React.ReactNode => {
|
|
252
318
|
const block = display[blockIndex]!;
|
|
253
|
-
const
|
|
319
|
+
const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
|
|
320
|
+
// list items of one list stay tight; every other block sits a blank row below its neighbour
|
|
321
|
+
const previous = display[blockIndex - 1];
|
|
322
|
+
const tight =
|
|
323
|
+
previous !== undefined &&
|
|
324
|
+
block.kind === previous.kind &&
|
|
325
|
+
(block.kind === "li" || block.kind === "oli");
|
|
326
|
+
// the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
|
|
327
|
+
// virtualizer's spacer math counts it
|
|
328
|
+
const leadingGap =
|
|
329
|
+
blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
|
|
330
|
+
const measuredBox = (children: React.ReactNode): React.ReactNode => (
|
|
331
|
+
<box
|
|
332
|
+
key={`discussion-block-${blockIndex}`}
|
|
333
|
+
id={`discussion-block-${blockIndex}`}
|
|
334
|
+
ref={virtual.measureRef(blockIndex)}
|
|
335
|
+
style={{ flexDirection: "column" }}
|
|
336
|
+
>
|
|
337
|
+
{leadingGap}
|
|
338
|
+
{children}
|
|
339
|
+
</box>
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
if (isGridKind(block.kind)) {
|
|
343
|
+
// a grid annotates as one unit: every row hit-tests to the same block span, a mark tints the grid,
|
|
344
|
+
// and the block's discussion cards slot under it, so annotations resolving into a grid stay visible
|
|
345
|
+
const gridLine: VisualLine = { start: 0, end: renderedText(block).length };
|
|
346
|
+
const marked = surface.rangesFor(blockIndex).some((range) => !range.caretOnly);
|
|
347
|
+
|
|
348
|
+
return measuredBox(
|
|
349
|
+
<>
|
|
350
|
+
<MarkdownGridBlock
|
|
351
|
+
block={block}
|
|
352
|
+
theme={tokens}
|
|
353
|
+
contentWidth={viewWidth > 0 ? viewWidth - 6 : 40}
|
|
354
|
+
marked={marked}
|
|
355
|
+
markBackdrop={palette.markBackdrop}
|
|
356
|
+
registerRow={(rowIndex) => surface.registerLine(blockIndex, rowIndex, gridLine)}
|
|
357
|
+
onRowMouseDown={surface.onLineMouseDown}
|
|
358
|
+
/>
|
|
359
|
+
{surface.cardsAfterLine(blockIndex, gridLine, true)}
|
|
360
|
+
</>,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const roleRuns = renderedStyleRuns(block);
|
|
254
365
|
const ranges = surface.rangesFor(blockIndex);
|
|
255
|
-
const
|
|
256
|
-
const lines = wrapLines(text, viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
|
|
366
|
+
const lines = wrapLines(renderedText(block), viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
|
|
257
367
|
const lineRows: React.ReactNode[] = [];
|
|
258
368
|
|
|
259
369
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
@@ -263,16 +373,16 @@ export function ThreadView({
|
|
|
263
373
|
lineRows.push(
|
|
264
374
|
lineRowFor({
|
|
265
375
|
blockIndex,
|
|
266
|
-
|
|
376
|
+
roleRuns,
|
|
267
377
|
line,
|
|
268
378
|
lineIndex,
|
|
269
379
|
ranges,
|
|
270
380
|
marker,
|
|
271
381
|
baseFg,
|
|
272
382
|
baseAttributes,
|
|
273
|
-
trailing: lineIndex === 0 ? changeTag : null,
|
|
274
383
|
}),
|
|
275
384
|
);
|
|
385
|
+
if (isLastLine) lineRows.push(headingRule(block));
|
|
276
386
|
const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
|
|
277
387
|
|
|
278
388
|
lineRows.push(...cards);
|
|
@@ -281,13 +391,7 @@ export function ThreadView({
|
|
|
281
391
|
}
|
|
282
392
|
}
|
|
283
393
|
|
|
284
|
-
//
|
|
285
|
-
// below its neighbour, and a code block announces its language first
|
|
286
|
-
const previous = display[blockIndex - 1];
|
|
287
|
-
const tight =
|
|
288
|
-
previous !== undefined &&
|
|
289
|
-
block.kind === previous.kind &&
|
|
290
|
-
(block.kind === "li" || block.kind === "oli");
|
|
394
|
+
// a code block announces its language first
|
|
291
395
|
const languageRow =
|
|
292
396
|
block.kind === "code" ? (
|
|
293
397
|
<text key="language" fg={tokens.textDim} selectable={false}>
|
|
@@ -295,18 +399,43 @@ export function ThreadView({
|
|
|
295
399
|
</text>
|
|
296
400
|
) : null;
|
|
297
401
|
|
|
298
|
-
return (
|
|
299
|
-
|
|
300
|
-
key={`discussion-block-${blockIndex}`}
|
|
301
|
-
id={`discussion-block-${blockIndex}`}
|
|
302
|
-
style={{ flexDirection: "column", marginTop: blockIndex === 0 || tight ? 0 : 1 }}
|
|
303
|
-
>
|
|
402
|
+
return measuredBox(
|
|
403
|
+
<>
|
|
304
404
|
{languageRow}
|
|
305
405
|
{lineRows}
|
|
306
|
-
|
|
406
|
+
</>,
|
|
307
407
|
);
|
|
308
408
|
};
|
|
309
409
|
|
|
410
|
+
const virtualBlocks = (): React.ReactNode[] => {
|
|
411
|
+
const nodes: React.ReactNode[] = [];
|
|
412
|
+
const firstItem = virtual.items[0];
|
|
413
|
+
const lastItem = virtual.items.at(-1);
|
|
414
|
+
|
|
415
|
+
// the very first render has no measured viewport yet, so the virtualizer's window is empty; paint a
|
|
416
|
+
// viewport-worth from the top rather than blank or the whole document, then it takes over next frame
|
|
417
|
+
if (!firstItem || !lastItem) {
|
|
418
|
+
let mountedRows = 0;
|
|
419
|
+
|
|
420
|
+
for (let index = 0; index < display.length; index++) {
|
|
421
|
+
nodes.push(blockNodeFor(index));
|
|
422
|
+
mountedRows += estimateBlockRows(index);
|
|
423
|
+
if (mountedRows > terminalHeight + OVERSCAN_BLOCKS) break;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return nodes;
|
|
427
|
+
}
|
|
428
|
+
if (firstItem.start > 0) {
|
|
429
|
+
nodes.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
|
|
430
|
+
}
|
|
431
|
+
for (const item of virtual.items) nodes.push(blockNodeFor(item.index));
|
|
432
|
+
const below = virtual.totalSize - lastItem.end;
|
|
433
|
+
|
|
434
|
+
if (below > 0) nodes.push(<box key="spacer-below" style={{ height: below }} />);
|
|
435
|
+
|
|
436
|
+
return nodes;
|
|
437
|
+
};
|
|
438
|
+
|
|
310
439
|
return (
|
|
311
440
|
<box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
|
|
312
441
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
@@ -324,12 +453,16 @@ export function ThreadView({
|
|
|
324
453
|
focused={false}
|
|
325
454
|
verticalScrollbarOptions={{ visible: false }}
|
|
326
455
|
>
|
|
327
|
-
{
|
|
456
|
+
{virtualBlocks()}
|
|
328
457
|
</scrollbox>
|
|
458
|
+
{suspended ? null : (
|
|
459
|
+
<NavModeHint navMode={surface.navMode} surface="thread" theme={tokens} />
|
|
460
|
+
)}
|
|
329
461
|
</box>
|
|
330
462
|
<DiscussionMarkerRail
|
|
331
463
|
discussions={discussions}
|
|
332
464
|
spanQuote={surface.spanQuote}
|
|
465
|
+
focusedKey={surface.focusedDiscussion}
|
|
333
466
|
onJump={surface.jumpToDiscussion}
|
|
334
467
|
scrollbox={scrollRef}
|
|
335
468
|
theme={theme}
|
|
@@ -10,3 +10,10 @@ export const ShareLink: Story = {
|
|
|
10
10
|
expectedColors: [DARK.accent],
|
|
11
11
|
size: { width: 60, height: 12 },
|
|
12
12
|
};
|
|
13
|
+
|
|
14
|
+
export const LongMessage: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<Toast body="could not stop sharing: gateway revoke failed - the share may already be gone, try again in a moment" />
|
|
17
|
+
),
|
|
18
|
+
size: { width: 90, height: 14 },
|
|
19
|
+
};
|