@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.78

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 (63) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.plan-review.test.tsx +16 -0
  6. package/src/App.submit-confirm.test.tsx +34 -5
  7. package/src/App.test.tsx +2 -2
  8. package/src/App.tsx +4 -0
  9. package/src/App.walk.test.tsx +2 -2
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/ansi-screen.test.ts +28 -0
  12. package/src/ansi-screen.ts +72 -0
  13. package/src/app-screens.tsx +7 -3
  14. package/src/app-view-model.ts +3 -0
  15. package/src/components/AnnotatableFileView.tsx +1 -1
  16. package/src/components/AnnotationCards.tsx +18 -2
  17. package/src/components/AnsiScreen.stories.tsx +12 -0
  18. package/src/components/AnsiScreen.tsx +28 -0
  19. package/src/components/CompletionOverlay.stories.tsx +15 -1
  20. package/src/components/CompletionOverlay.tsx +22 -5
  21. package/src/components/ConfirmCard.stories.tsx +2 -0
  22. package/src/components/ConfirmCard.tsx +45 -18
  23. package/src/components/ConfirmDialog.tsx +11 -12
  24. package/src/components/DiffContentView.tsx +4 -4
  25. package/src/components/FileContentsView.tsx +1 -1
  26. package/src/components/ManageAccessDialog.tsx +13 -11
  27. package/src/components/PromptDialog.test.tsx +2 -2
  28. package/src/components/PromptDialog.tsx +9 -10
  29. package/src/components/ShareChoiceDialog.tsx +0 -2
  30. package/src/components/SlashComposer.stories.tsx +37 -0
  31. package/src/components/SlashComposer.tsx +87 -0
  32. package/src/components/ThreadTree.tsx +11 -6
  33. package/src/components/ThreadView.selection.test.tsx +88 -1
  34. package/src/components/ThreadView.slash.test.tsx +0 -1
  35. package/src/components/ThreadView.tsx +66 -7
  36. package/src/components/Toast.tsx +0 -5
  37. package/src/components/WalkWizard.stories.tsx +2 -2
  38. package/src/components/WalkWizard.tsx +10 -12
  39. package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
  40. package/src/components/primitives/Card.tsx +3 -0
  41. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  42. package/src/components/primitives/DialogActions.tsx +39 -0
  43. package/src/components/session-tree.ts +7 -6
  44. package/src/components/stories-app.tsx +13 -4
  45. package/src/components/stories.test.tsx +8 -1
  46. package/src/components/story-tree.test.ts +32 -24
  47. package/src/components/story.ts +32 -15
  48. package/src/diff-intraline.test.ts +75 -2
  49. package/src/diff-intraline.ts +95 -26
  50. package/src/keymap.test.ts +2 -2
  51. package/src/keymap.ts +0 -1
  52. package/src/pasted-image.test.ts +67 -0
  53. package/src/pasted-image.ts +60 -0
  54. package/src/run.ts +31 -19
  55. package/src/serve.ts +1 -1
  56. package/src/share.ts +1 -1
  57. package/src/slash-palette.test.ts +17 -0
  58. package/src/slash-palette.ts +11 -0
  59. package/src/thread-controller.derivation.test.ts +62 -0
  60. package/src/thread-controller.optimistic.test.ts +69 -0
  61. package/src/thread-controller.switch-cache.test.ts +146 -0
  62. package/src/thread-controller.ts +157 -24
  63. package/src/use-annotation-surface.tsx +43 -27
@@ -5,13 +5,21 @@
5
5
  * derives from the content rows through Card, so layout and render never drift.
6
6
  */
7
7
 
8
- import React from "react";
8
+ import React, { useContext } from "react";
9
+ import { useTerminalDimensions } from "@opentui/react";
9
10
  import type { VerdictKind } from "@cueloop/schema";
10
11
  import type { Theme } from "../theme";
12
+ import type { QuickAction } from "../config";
13
+ import { SlashSkillsContext } from "../skills";
14
+ import { activeSlashToken, mergeSlashItems, slashFilter, slashItemsFrom } from "../slash-palette";
11
15
  import { useComponentTheme } from "./theme-context";
12
16
  import { Card } from "./primitives/Card";
13
- import { Button } from "./primitives/Button";
14
- import { Toolbar } from "./primitives/Toolbar";
17
+ import { composeRowCount } from "./AnnotationCards";
18
+ import { SlashComposer } from "./SlashComposer";
19
+ import { DialogActions } from "./primitives/DialogActions";
20
+
21
+ const SUBMIT_CARD_MAX_WIDTH = 48;
22
+ const PALETTE_WINDOW = 5;
15
23
 
16
24
  export const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
17
25
 
@@ -34,6 +42,7 @@ export interface ConfirmCardProps {
34
42
  onSelectVerdict: (verdict: VerdictKind) => void;
35
43
  onSubmit: () => void;
36
44
  onCancel: () => void;
45
+ quickActions: QuickAction[];
37
46
  theme?: Theme;
38
47
  }
39
48
 
@@ -66,7 +75,7 @@ function VerdictSelector({
66
75
  const tokens = useComponentTheme(theme);
67
76
 
68
77
  return (
69
- <box style={{ flexDirection: "row", height: 1 }}>
78
+ <box style={{ flexDirection: "row", height: 1, width: "100%", justifyContent: "center" }}>
70
79
  {VERDICTS.map((candidate) => (
71
80
  <box
72
81
  key={candidate}
@@ -92,14 +101,33 @@ export function ConfirmCard({
92
101
  onSelectVerdict,
93
102
  onSubmit,
94
103
  onCancel,
104
+ quickActions,
95
105
  theme,
96
106
  }: ConfirmCardProps): React.ReactNode {
97
107
  const tokens = useComponentTheme(theme);
108
+ const skills = useContext(SlashSkillsContext);
109
+ const { width: terminalWidth } = useTerminalDimensions();
110
+ const cardWidth = Math.max(24, Math.min(terminalWidth - 6, SUBMIT_CARD_MAX_WIDTH));
111
+
112
+ const composerRows = composeRowCount(summary, cardWidth - 4);
113
+ const token = activeSlashToken(summary, summary.length);
114
+ const paletteItems =
115
+ token !== null
116
+ ? slashFilter(mergeSlashItems(slashItemsFrom(quickActions), skills), token.slice(1))
117
+ : [];
118
+ const paletteRows =
119
+ paletteItems.length > 0
120
+ ? Math.min(PALETTE_WINDOW, paletteItems.length) +
121
+ (paletteItems.length > PALETTE_WINDOW ? 1 : 0)
122
+ : 0;
123
+ const contentRows =
124
+ CONFIRM_CONTENT_ROWS - 1 + composerRows + paletteRows + (viewedSummary !== undefined ? 2 : 0);
98
125
 
99
126
  return (
100
127
  <Card
101
- contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
102
- borderColor={tokens.text}
128
+ contentRows={contentRows}
129
+ width={cardWidth}
130
+ borderColor={tokens.accent}
103
131
  backgroundColor={tokens.elevated}
104
132
  theme={theme}
105
133
  >
@@ -107,21 +135,20 @@ export function ConfirmCard({
107
135
  {viewedSummary !== undefined ? <box style={{ height: 1 }} /> : null}
108
136
  <VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
109
137
  <box style={{ height: 1 }} />
110
- <input
111
- focused
112
- value={summary}
113
- onInput={onInput}
138
+ <SlashComposer
139
+ seed={summary}
140
+ glyph=""
141
+ quickActions={quickActions}
142
+ tokens={tokens}
114
143
  placeholder="summary for the agent (optional)"
144
+ onSubmit={(text) => {
145
+ onInput(text);
146
+ onSubmit();
147
+ }}
148
+ onInput={onInput}
115
149
  />
116
150
  <box style={{ height: 1 }} />
117
- <Toolbar>
118
- <Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
119
- {" send message "}
120
- </Button>
121
- <Button onPress={onCancel} theme={theme}>
122
- {" Cancel "}
123
- </Button>
124
- </Toolbar>
151
+ <DialogActions confirmLabel="send" onConfirm={onSubmit} onCancel={onCancel} theme={theme} />
125
152
  </Card>
126
153
  );
127
154
  }
@@ -10,8 +10,7 @@ import { useTerminalDimensions } from "@opentui/react";
10
10
  import type { Theme } from "../theme";
11
11
  import { useComponentTheme } from "./theme-context";
12
12
  import { Dialog } from "./primitives/Dialog";
13
- import { Button } from "./primitives/Button";
14
- import { Toolbar } from "./primitives/Toolbar";
13
+ import { DialogActions } from "./primitives/DialogActions";
15
14
 
16
15
  export interface ConfirmDialogProps {
17
16
  isOpen: boolean;
@@ -28,8 +27,8 @@ export function ConfirmDialog({
28
27
  isOpen,
29
28
  title,
30
29
  message,
31
- confirmLabel = "Delete",
32
- cancelLabel = "Cancel",
30
+ confirmLabel = "delete",
31
+ cancelLabel = "cancel",
33
32
  onConfirm,
34
33
  onCancel,
35
34
  theme,
@@ -45,6 +44,7 @@ export function ConfirmDialog({
45
44
  title={title}
46
45
  width={Math.min(54, terminalWidth - 6)}
47
46
  height={7}
47
+ background={tokens.elevated}
48
48
  onDismiss={onCancel}
49
49
  theme={theme}
50
50
  >
@@ -59,14 +59,13 @@ export function ConfirmDialog({
59
59
  >
60
60
  <text fg={tokens.text}>{message}</text>
61
61
  <box style={{ flexGrow: 1 }} />
62
- <Toolbar>
63
- <Button variant="solid" onPress={onConfirm} theme={theme}>
64
- {` ${confirmLabel} `}
65
- </Button>
66
- <Button onPress={onCancel} theme={theme}>
67
- {` ${cancelLabel} esc `}
68
- </Button>
69
- </Toolbar>
62
+ <DialogActions
63
+ confirmLabel={confirmLabel}
64
+ cancelLabel={cancelLabel}
65
+ onConfirm={onConfirm}
66
+ onCancel={onCancel}
67
+ theme={theme}
68
+ />
70
69
  </box>
71
70
  </Dialog>
72
71
  );
@@ -18,7 +18,7 @@ import type { Theme } from "../theme";
18
18
  import { useComponentTheme } from "./theme-context";
19
19
  import { IconButton } from "./primitives/IconButton";
20
20
  import { NERD } from "./primitives/icons";
21
- import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
21
+ import { createIntralineResolver, type IntralineRun } from "../diff-intraline";
22
22
  import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
23
23
  import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
24
24
  import { UNDERLINE, type AnnotationPalette } from "../annotation-palette";
@@ -535,7 +535,7 @@ export function DiffContentView({
535
535
  const { palette } = surface;
536
536
  const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
537
537
  const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
538
- const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
538
+ const intralineResolver = useMemo(() => createIntralineResolver(rows), [rows]);
539
539
  const syntaxByRow = useSyntaxHighlights(rows);
540
540
  const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
541
541
  const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
@@ -595,13 +595,13 @@ export function DiffContentView({
595
595
  const fgByColumn = foregroundColumns(
596
596
  text,
597
597
  rowBaseColor(row, tokens),
598
- intralineByRow.get(rowIndex),
598
+ intralineResolver.runsForRow(rowIndex),
599
599
  syntaxByRow.get(rowIndex),
600
600
  tokens,
601
601
  );
602
602
  const emphasisBgByColumn = emphasisBackgroundColumns(
603
603
  text,
604
- intralineByRow.get(rowIndex),
604
+ intralineResolver.runsForRow(rowIndex),
605
605
  rowEmphasisBackground(row, tokens),
606
606
  );
607
607
  const rowBg = rowBackground(row, tokens);
@@ -1,5 +1,5 @@
1
1
  // A Changes/Project file tab in contents mode: a workspace file rendered read-only with line
2
- // numbers, for surfaces with no review session to anchor notes to (the bare-launch welcome shell).
2
+ // numbers, for surfaces with no thread to anchor notes to (the bare-launch welcome shell).
3
3
  // Loads on mount and whenever the path changes; a null read renders a centered "File deleted".
4
4
  // Syntax highlighting comes from the native code renderable (tree-sitter), the language
5
5
  // auto-detected from the path; unknown languages simply render unstyled.
@@ -11,6 +11,8 @@ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
11
  import type { Theme } from "../theme";
12
12
  import { useComponentTheme } from "./theme-context";
13
13
  import { Dialog } from "./primitives/Dialog";
14
+ import { Button } from "./primitives/Button";
15
+ import { Toolbar } from "./primitives/Toolbar";
14
16
 
15
17
  // enter adds the handle rather than inserting a newline into the one-line field
16
18
  const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
@@ -103,17 +105,17 @@ export function ManageAccessDialog({
103
105
  }}
104
106
  />
105
107
  </box>
106
- <box style={{ flexDirection: "row" }}>
107
- <box onMouseUp={addDraft} style={{ marginRight: 3 }}>
108
- <text fg={tokens.accent}>add</text>
109
- </box>
110
- <box onMouseUp={onCreateLink} style={{ marginRight: 3 }}>
111
- <text fg={tokens.accent}>create link</text>
112
- </box>
113
- <box onMouseUp={onClose}>
114
- <text fg={tokens.textDim}>close</text>
115
- </box>
116
- </box>
108
+ <Toolbar>
109
+ <Button marginRight={2} onPress={addDraft} theme={theme}>
110
+ {" add "}
111
+ </Button>
112
+ <Button variant="solid" marginRight={2} onPress={onCreateLink} theme={theme}>
113
+ {" create link "}
114
+ </Button>
115
+ <Button onPress={onClose} theme={theme}>
116
+ {" close "}
117
+ </Button>
118
+ </Toolbar>
117
119
  </box>
118
120
  </Dialog>
119
121
  );
@@ -47,12 +47,12 @@ test("clicking save fires onSave and clicking cancel fires onCancel", async () =
47
47
  );
48
48
  await settle(setup);
49
49
 
50
- const save = locateText(setup, "enter save");
50
+ const save = locateText(setup, "save");
51
51
  await setup.mockMouse.click(save.column, save.row);
52
52
  expect(saved).toBe(1);
53
53
  expect(cancelled).toBe(0);
54
54
 
55
- const cancel = locateText(setup, "esc cancel");
55
+ const cancel = locateText(setup, "cancel");
56
56
  await setup.mockMouse.click(cancel.column, cancel.row);
57
57
  expect(cancelled).toBe(1);
58
58
  expect(saved).toBe(1);
@@ -11,6 +11,7 @@ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
11
  import type { Theme } from "../theme";
12
12
  import { useComponentTheme } from "./theme-context";
13
13
  import { Dialog } from "./primitives/Dialog";
14
+ import { DialogActions } from "./primitives/DialogActions";
14
15
 
15
16
  // ⏎ submits (suppresses the textarea's default newline); the grammar owns save.
16
17
  const PROMPT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
@@ -18,7 +19,7 @@ const PROMPT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }]
18
19
  export interface PromptDialogProps {
19
20
  isOpen: boolean;
20
21
  title?: string;
21
- label: string;
22
+ label?: string;
22
23
  value: string;
23
24
  placeholder?: string;
24
25
  onInput: (text: string) => void;
@@ -69,7 +70,7 @@ export function PromptDialog({
69
70
  paddingTop: 1,
70
71
  }}
71
72
  >
72
- <text fg={tokens.textDim}>{label}</text>
73
+ {label ? <text fg={tokens.textDim}>{label}</text> : null}
73
74
  <textarea
74
75
  ref={inputRef}
75
76
  focused
@@ -86,14 +87,12 @@ export function PromptDialog({
86
87
  }}
87
88
  />
88
89
  <box style={{ flexGrow: 1 }} />
89
- <box style={{ flexDirection: "row" }}>
90
- <box onMouseUp={onSave} style={{ marginRight: 3 }}>
91
- <text fg={tokens.textDim}>enter save</text>
92
- </box>
93
- <box onMouseUp={onCancel}>
94
- <text fg={tokens.textDim}>esc cancel</text>
95
- </box>
96
- </box>
90
+ <DialogActions
91
+ confirmLabel="save"
92
+ onConfirm={() => onSave?.()}
93
+ onCancel={() => onCancel?.()}
94
+ theme={theme}
95
+ />
97
96
  </box>
98
97
  </Dialog>
99
98
  );
@@ -74,8 +74,6 @@ export function ShareChoiceDialog({
74
74
  </text>
75
75
  </box>
76
76
  ))}
77
- <box style={{ flexGrow: 1 }} />
78
- <text fg={tokens.textDim}>↑↓ choose · enter select · esc cancel</text>
79
77
  </box>
80
78
  </Dialog>
81
79
  );
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { SlashComposer } from "./SlashComposer";
6
+
7
+ export const meta: StoryMeta = { title: "Chrome/SlashComposer" };
8
+
9
+ export const Empty: Story = {
10
+ render: () => (
11
+ <box style={{ width: 60, height: 8, paddingLeft: 1, backgroundColor: DARK.panel }}>
12
+ <SlashComposer
13
+ seed=""
14
+ glyph="●"
15
+ quickActions={DEFAULT_QUICK_ACTIONS}
16
+ tokens={DARK}
17
+ onSubmit={() => {}}
18
+ />
19
+ </box>
20
+ ),
21
+ size: { width: 60, height: 8 },
22
+ };
23
+
24
+ export const WithDraft: Story = {
25
+ render: () => (
26
+ <box style={{ width: 60, height: 8, paddingLeft: 1, backgroundColor: DARK.panel }}>
27
+ <SlashComposer
28
+ seed="looks good, ship it"
29
+ glyph="●"
30
+ quickActions={DEFAULT_QUICK_ACTIONS}
31
+ tokens={DARK}
32
+ onSubmit={() => {}}
33
+ />
34
+ </box>
35
+ ),
36
+ size: { width: 60, height: 8 },
37
+ };
@@ -0,0 +1,87 @@
1
+ /** A composer with the "/" actions-and-skills palette, sharing the inline-comment edit experience. */
2
+
3
+ import React, { useContext, useState } from "react";
4
+ import { useKeyboard } from "@opentui/react";
5
+ import type { Theme } from "../theme";
6
+ import type { QuickAction } from "../config";
7
+ import { SlashSkillsContext } from "../skills";
8
+ import {
9
+ activeSlashToken,
10
+ insertSlashItem,
11
+ mergeSlashItems,
12
+ slashFilter,
13
+ slashItemsFrom,
14
+ } from "../slash-palette";
15
+ import { Composer, ComposerPalette } from "./AnnotationCards";
16
+
17
+ export interface SlashComposerProps {
18
+ seed: string;
19
+ glyph: string;
20
+ quickActions: QuickAction[];
21
+ tokens: Theme;
22
+ onSubmit: (text: string) => void;
23
+ onInput?: (text: string) => void;
24
+ placeholder?: string;
25
+ }
26
+
27
+ export function SlashComposer({
28
+ seed,
29
+ glyph,
30
+ quickActions,
31
+ tokens,
32
+ onSubmit,
33
+ onInput,
34
+ placeholder,
35
+ }: SlashComposerProps): React.ReactNode {
36
+ const skills = useContext(SlashSkillsContext);
37
+ const [mountSeed, setMountSeed] = useState(seed);
38
+ const [text, setText] = useState(seed);
39
+ const [caret, setCaret] = useState(seed.length);
40
+ const [slashIndex, setSlashIndex] = useState(0);
41
+
42
+ const paletteItems = mergeSlashItems(slashItemsFrom(quickActions), skills);
43
+ const token = activeSlashToken(text, caret);
44
+ const items = token !== null ? slashFilter(paletteItems, token.slice(1)) : [];
45
+ const slashActive = token !== null && items.length > 0;
46
+
47
+ useKeyboard((key) => {
48
+ if (!slashActive) return;
49
+ const selected = Math.min(slashIndex, items.length - 1);
50
+
51
+ if (key.name === "up") return setSlashIndex(Math.max(0, selected - 1));
52
+ if (key.name === "down") return setSlashIndex(Math.min(items.length - 1, selected + 1));
53
+ if (key.name === "tab" || (key.name === "return" && !key.meta && !key.ctrl && !key.super)) {
54
+ const inserted = insertSlashItem(text, caret, items[selected]!.name);
55
+
56
+ setMountSeed(inserted.text);
57
+ setText(inserted.text);
58
+ setCaret(inserted.caret);
59
+ onInput?.(inserted.text);
60
+ }
61
+ });
62
+
63
+ return (
64
+ <box style={{ flexDirection: "column" }}>
65
+ <Composer
66
+ key={mountSeed}
67
+ seed={mountSeed}
68
+ glyph={glyph}
69
+ tokens={tokens}
70
+ placeholder={placeholder}
71
+ onReady={() => {}}
72
+ onSave={onSubmit}
73
+ onInput={(next, nextCaret) => {
74
+ setText(next);
75
+ setCaret(nextCaret);
76
+ onInput?.(next);
77
+ }}
78
+ />
79
+ <ComposerPalette
80
+ slashActive={slashActive}
81
+ slashItems={items}
82
+ slashIndex={slashIndex}
83
+ tokens={tokens}
84
+ />
85
+ </box>
86
+ );
87
+ }
@@ -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,
@@ -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
+ });
@@ -90,7 +90,6 @@ describe("the command leader", () => {
90
90
 
91
91
  // the command fired and no composer opened (a bare "x" would have started a draft)
92
92
  expect(commands).toEqual(["x"]);
93
- expect(setup.captureCharFrame()).not.toContain("enter save");
94
93
 
95
94
  setup.renderer.destroy();
96
95
  });