@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.
Files changed (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. 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.red, onDelete)}
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 } = props;
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
- <text wrapMode="none">
161
- <span fg={tokens.textDim}>{pinned ? ` ${NERD.star} ` : " "}</span>
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
- titleWidth={titleWidth}
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 { displayText, type DisplayBlock, type Mark } from "../view-plan";
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 { lineMarkRanges, runsFor, wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
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, and tracked changes as the plan sheet drew them - a cut block
35
- * dim and struck through, an added or edited block tagged on its first row.
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: "tab", label: "fold / unfold" },
88
- { keys: "⌘] / ⌘[", label: "next / previous comment" },
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
- leaderCombos?: readonly string[];
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
- leaderCombos,
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
- textAt: (blockIndex) => displayText(display[blockIndex]!),
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
- leaderCombos,
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
- try {
199
- scrollRef.current?.scrollChildIntoView(`discussion-block-${surface.revealBlockIndex}`);
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
- text: string;
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, text, line, lineIndex, ranges, marker, baseFg, baseAttributes, trailing } =
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
- {runsFor(text.slice(line.start, line.end), lineRanges).map((run, runIndex) => (
236
- <span
237
- key={runIndex}
238
- fg={baseFg}
239
- bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
240
- attributes={baseAttributes | (run.marked ? UNDERLINE : 0)}
241
- >
242
- {run.text}
243
- </span>
244
- ))}
245
- {trailing ? <span fg={trailing.fg}>{trailing.text}</span> : null}
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 text = displayText(block);
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 { baseFg, baseAttributes, marker, changeTag } = blockStyle(block, tokens);
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
- text,
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
- // list items of one list stay tight; every other block sits a blank row
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
- <box
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
- </box>
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
- {display.map((_block, blockIndex) => blockNodeFor(blockIndex))}
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
+ };