@cueloop/client 0.1.0-alpha.76 → 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 (82) 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.identity.test.tsx +78 -0
  6. package/src/App.plan-review.test.tsx +16 -0
  7. package/src/App.share-choice.test.tsx +105 -0
  8. package/src/App.submit-confirm.test.tsx +34 -5
  9. package/src/App.test.tsx +2 -2
  10. package/src/App.toast.test.tsx +3 -1
  11. package/src/App.tsx +180 -5
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  14. package/src/ansi-screen.test.ts +28 -0
  15. package/src/ansi-screen.ts +72 -0
  16. package/src/app-screens.tsx +20 -3
  17. package/src/app-view-model.ts +4 -0
  18. package/src/components/AnnotatableFileView.tsx +10 -2
  19. package/src/components/AnnotationCards.tsx +40 -6
  20. package/src/components/AnsiScreen.stories.tsx +12 -0
  21. package/src/components/AnsiScreen.tsx +28 -0
  22. package/src/components/CompletionOverlay.stories.tsx +15 -1
  23. package/src/components/CompletionOverlay.tsx +22 -5
  24. package/src/components/ConfirmCard.stories.tsx +2 -0
  25. package/src/components/ConfirmCard.tsx +45 -18
  26. package/src/components/ConfirmDialog.tsx +11 -12
  27. package/src/components/DiffContentView.tsx +9 -5
  28. package/src/components/FileContentsView.tsx +1 -1
  29. package/src/components/GridTabContent.tsx +2 -0
  30. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  31. package/src/components/ManageAccessDialog.test.tsx +94 -0
  32. package/src/components/ManageAccessDialog.tsx +122 -0
  33. package/src/components/PromptDialog.test.tsx +2 -2
  34. package/src/components/PromptDialog.tsx +9 -10
  35. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  36. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  37. package/src/components/ShareChoiceDialog.tsx +80 -0
  38. package/src/components/SlashComposer.stories.tsx +37 -0
  39. package/src/components/SlashComposer.tsx +87 -0
  40. package/src/components/ThreadTree.tsx +11 -6
  41. package/src/components/ThreadView.selection.test.tsx +88 -1
  42. package/src/components/ThreadView.slash.test.tsx +0 -1
  43. package/src/components/ThreadView.tsx +71 -8
  44. package/src/components/Toast.tsx +0 -5
  45. package/src/components/WalkWizard.stories.tsx +2 -2
  46. package/src/components/WalkWizard.tsx +10 -12
  47. package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
  48. package/src/components/primitives/Card.tsx +3 -0
  49. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  50. package/src/components/primitives/DialogActions.tsx +39 -0
  51. package/src/components/session-tree.ts +7 -6
  52. package/src/components/stories-app.tsx +13 -4
  53. package/src/components/stories.test.tsx +8 -1
  54. package/src/components/story-tree.test.ts +32 -24
  55. package/src/components/story.ts +32 -15
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +44 -1
  58. package/src/diff-intraline.test.ts +75 -2
  59. package/src/diff-intraline.ts +95 -26
  60. package/src/github-identity.test.ts +58 -0
  61. package/src/github-identity.ts +41 -0
  62. package/src/intent-dispatch.test.ts +17 -2
  63. package/src/intent-dispatch.ts +7 -1
  64. package/src/keymap.test.ts +2 -2
  65. package/src/keymap.ts +1 -1
  66. package/src/pasted-image.test.ts +67 -0
  67. package/src/pasted-image.ts +60 -0
  68. package/src/run.ts +31 -19
  69. package/src/serve.ts +1 -1
  70. package/src/share.ts +11 -3
  71. package/src/slash-palette.test.ts +17 -0
  72. package/src/slash-palette.ts +11 -0
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.derivation.test.ts +62 -0
  75. package/src/thread-controller.live-diff.test.ts +1 -0
  76. package/src/thread-controller.optimistic.test.ts +69 -0
  77. package/src/thread-controller.share.test.ts +36 -2
  78. package/src/thread-controller.switch-cache.test.ts +146 -0
  79. package/src/thread-controller.tree.test.ts +1 -0
  80. package/src/thread-controller.ts +171 -24
  81. package/src/use-annotation-surface.tsx +53 -29
  82. package/src/use-settings-dialog.tsx +24 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Owner surface for a private share's allowlist: the GitHub logins allowed to
3
+ * open the private link. Type a handle and add it; click a row to remove one.
4
+ * Editing persists through the controller, so the list survives a reload.
5
+ */
6
+
7
+ import React, { useEffect, useRef, useState } from "react";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import { useTerminalDimensions } from "@opentui/react";
10
+ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
+ import type { Theme } from "../theme";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import { Dialog } from "./primitives/Dialog";
14
+ import { Button } from "./primitives/Button";
15
+ import { Toolbar } from "./primitives/Toolbar";
16
+
17
+ // enter adds the handle rather than inserting a newline into the one-line field
18
+ const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
19
+
20
+ export interface ManageAccessDialogProps {
21
+ isOpen: boolean;
22
+ logins: string[];
23
+ onAdd: (login: string) => void;
24
+ onRemove: (login: string) => void;
25
+ /** Publish the private link with the current allowlist and copy its connection line. */
26
+ onCreateLink: () => void;
27
+ onClose: () => void;
28
+ theme?: Theme;
29
+ }
30
+
31
+ export function ManageAccessDialog({
32
+ isOpen,
33
+ logins,
34
+ onAdd,
35
+ onRemove,
36
+ onCreateLink,
37
+ onClose,
38
+ theme,
39
+ }: ManageAccessDialogProps): React.ReactNode {
40
+ const tokens = useComponentTheme(theme);
41
+ const { width: terminalWidth } = useTerminalDimensions();
42
+ const inputRef = useRef<TextareaRenderable | null>(null);
43
+ const [draft, setDraft] = useState("");
44
+ const [inputGeneration, setInputGeneration] = useState(0);
45
+
46
+ useEffect(() => {
47
+ inputRef.current?.focus();
48
+ }, [inputGeneration]);
49
+
50
+ const addDraft = (): void => {
51
+ const handle = draft.trim().replace(/^@/, "").toLowerCase();
52
+
53
+ if (handle && !logins.includes(handle)) onAdd(handle);
54
+ setDraft("");
55
+ setInputGeneration((generation) => generation + 1);
56
+ };
57
+
58
+ useKeyboard((key) => {
59
+ if (isOpen && key.name === "escape") onClose();
60
+ });
61
+
62
+ if (!isOpen) return null;
63
+
64
+ return (
65
+ <Dialog
66
+ isOpen
67
+ title=" Manage access "
68
+ width={Math.min(54, terminalWidth - 6)}
69
+ height={Math.min(18, 9 + logins.length)}
70
+ background={tokens.elevated}
71
+ onDismiss={onClose}
72
+ theme={theme}
73
+ >
74
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
75
+ <text fg={tokens.textDim}>Only these GitHub users can open the private link.</text>
76
+ <box style={{ height: 1 }} />
77
+ {logins.length === 0 ? (
78
+ <text fg={tokens.textDim}>no one yet - add a handle below</text>
79
+ ) : (
80
+ logins.map((login) => (
81
+ <box key={login} onMouseUp={() => onRemove(login)} style={{ flexDirection: "row" }}>
82
+ <text fg={tokens.text}>{`@${login}`}</text>
83
+ <box style={{ flexGrow: 1 }} />
84
+ <text fg={tokens.textDim}>remove</text>
85
+ </box>
86
+ ))
87
+ )}
88
+ <box style={{ flexGrow: 1 }} />
89
+ <box style={{ flexDirection: "row" }}>
90
+ <text fg={tokens.textDim}>{"@"}</text>
91
+ <textarea
92
+ key={inputGeneration}
93
+ ref={inputRef}
94
+ focused
95
+ placeholder="handle"
96
+ keyBindings={ADD_KEY_BINDINGS}
97
+ onContentChange={() => setDraft(inputRef.current?.plainText ?? "")}
98
+ style={{
99
+ height: 1,
100
+ flexGrow: 1,
101
+ backgroundColor: tokens.elevated,
102
+ focusedBackgroundColor: tokens.elevated,
103
+ textColor: tokens.text,
104
+ focusedTextColor: tokens.text,
105
+ }}
106
+ />
107
+ </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>
119
+ </box>
120
+ </Dialog>
121
+ );
122
+ }
@@ -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
  );
@@ -0,0 +1,34 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ShareChoiceDialog } from "./ShareChoiceDialog";
5
+
6
+ export const meta: StoryMeta = { title: "Dialogs/ShareChoice" };
7
+
8
+ export const PublicSelected: Story = {
9
+ render: () => (
10
+ <ShareChoiceDialog
11
+ isOpen
12
+ selectedIndex={0}
13
+ onPublicShare={() => {}}
14
+ onPrivateShare={() => {}}
15
+ onClose={() => {}}
16
+ theme={DARK}
17
+ />
18
+ ),
19
+ size: { width: 60, height: 12 },
20
+ };
21
+
22
+ export const PrivateSelected: Story = {
23
+ render: () => (
24
+ <ShareChoiceDialog
25
+ isOpen
26
+ selectedIndex={1}
27
+ onPublicShare={() => {}}
28
+ onPrivateShare={() => {}}
29
+ onClose={() => {}}
30
+ theme={DARK}
31
+ />
32
+ ),
33
+ size: { width: 60, height: 12 },
34
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../theme";
5
+ import { clickText, settle } from "../test-support";
6
+ import { ShareChoiceDialog } from "./ShareChoiceDialog";
7
+
8
+ interface ShareCounts {
9
+ publicShares: number;
10
+ privateShares: number;
11
+ closes: number;
12
+ }
13
+
14
+ async function renderDialog(selectedIndex = 0): Promise<{
15
+ setup: Awaited<ReturnType<typeof testRender>>;
16
+ counts: ShareCounts;
17
+ }> {
18
+ const counts: ShareCounts = { publicShares: 0, privateShares: 0, closes: 0 };
19
+ const setup = await testRender(
20
+ <box style={{ width: 60, height: 12 }}>
21
+ <ShareChoiceDialog
22
+ isOpen
23
+ selectedIndex={selectedIndex}
24
+ onPublicShare={() => {
25
+ counts.publicShares += 1;
26
+ }}
27
+ onPrivateShare={() => {
28
+ counts.privateShares += 1;
29
+ }}
30
+ onClose={() => {
31
+ counts.closes += 1;
32
+ }}
33
+ theme={DARK}
34
+ />
35
+ </box>,
36
+ { width: 60, height: 12 },
37
+ );
38
+
39
+ await settle(setup);
40
+
41
+ return { setup, counts };
42
+ }
43
+
44
+ describe("ShareChoiceDialog", () => {
45
+ test("offers both the public and private choice", async () => {
46
+ // Arrange + Act
47
+ const { setup } = await renderDialog();
48
+
49
+ // Assert
50
+ const frame = setup.captureCharFrame();
51
+
52
+ expect(frame).toContain("public link");
53
+ expect(frame).toContain("private link");
54
+ });
55
+
56
+ test("clicking the public choice publishes and closes", async () => {
57
+ // Arrange
58
+ const { setup, counts } = await renderDialog();
59
+
60
+ // Act
61
+ await clickText(setup, "public link");
62
+
63
+ // Assert
64
+ expect(counts.publicShares).toBe(1);
65
+ expect(counts.privateShares).toBe(0);
66
+ expect(counts.closes).toBe(1);
67
+ });
68
+
69
+ test("clicking the private choice opens manage-access and closes", async () => {
70
+ // Arrange
71
+ const { setup, counts } = await renderDialog();
72
+
73
+ // Act
74
+ await clickText(setup, "private link");
75
+
76
+ // Assert
77
+ expect(counts.privateShares).toBe(1);
78
+ expect(counts.publicShares).toBe(0);
79
+ expect(counts.closes).toBe(1);
80
+ });
81
+
82
+ test("renders nothing while closed", async () => {
83
+ // Arrange + Act
84
+ const setup = await testRender(
85
+ <box style={{ width: 60, height: 12 }}>
86
+ <ShareChoiceDialog
87
+ isOpen={false}
88
+ selectedIndex={0}
89
+ onPublicShare={() => {}}
90
+ onPrivateShare={() => {}}
91
+ onClose={() => {}}
92
+ theme={DARK}
93
+ />
94
+ </box>,
95
+ { width: 60, height: 12 },
96
+ );
97
+
98
+ await settle(setup);
99
+
100
+ // Assert
101
+ expect(setup.captureCharFrame()).not.toContain("public link");
102
+ });
103
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The share public/private choice, as an app-level overlay the share shortcut
3
+ * opens from any view. Public publishes a link for anyone; private opens the
4
+ * manage-access allowlist surface. The header share button and the shortcut both
5
+ * open this one surface. Selection moves with the keyboard (owned by the App) or
6
+ * a pointer click on a row.
7
+ */
8
+
9
+ import React from "react";
10
+ import { useTerminalDimensions } from "@opentui/react";
11
+ import type { Theme } from "../theme";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import { Dialog } from "./primitives/Dialog";
14
+
15
+ export type ShareChoice = "public" | "private";
16
+
17
+ interface ShareChoiceRow {
18
+ choice: ShareChoice;
19
+ label: string;
20
+ }
21
+
22
+ export const SHARE_CHOICES: ShareChoiceRow[] = [
23
+ { choice: "public", label: "public link" },
24
+ { choice: "private", label: "private link" },
25
+ ];
26
+
27
+ export interface ShareChoiceDialogProps {
28
+ isOpen: boolean;
29
+ /** The highlighted row; the App drives it from the shareChoice mode. */
30
+ selectedIndex: number;
31
+ /** Publish a public link and copy its connection line. */
32
+ onPublicShare: () => void;
33
+ /** Open the private-share manage-access allowlist surface. */
34
+ onPrivateShare: () => void;
35
+ onClose: () => void;
36
+ theme?: Theme;
37
+ }
38
+
39
+ export function ShareChoiceDialog({
40
+ isOpen,
41
+ selectedIndex,
42
+ onPublicShare,
43
+ onPrivateShare,
44
+ onClose,
45
+ theme,
46
+ }: ShareChoiceDialogProps): React.ReactNode {
47
+ const tokens = useComponentTheme(theme);
48
+ const { width: terminalWidth } = useTerminalDimensions();
49
+
50
+ const pick = (choice: ShareChoice): void => {
51
+ onClose();
52
+ if (choice === "public") onPublicShare();
53
+ else onPrivateShare();
54
+ };
55
+
56
+ if (!isOpen) return null;
57
+
58
+ return (
59
+ <Dialog
60
+ isOpen
61
+ title=" Share "
62
+ width={Math.min(46, terminalWidth - 6)}
63
+ height={7}
64
+ background={tokens.elevated}
65
+ onDismiss={onClose}
66
+ theme={theme}
67
+ >
68
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
69
+ {SHARE_CHOICES.map((row, index) => (
70
+ <box key={row.choice} onMouseUp={() => pick(row.choice)} style={{ flexDirection: "row" }}>
71
+ <text fg={index === selectedIndex ? tokens.accent : tokens.text}>
72
+ {index === selectedIndex ? "› " : " "}
73
+ {row.label}
74
+ </text>
75
+ </box>
76
+ ))}
77
+ </box>
78
+ </Dialog>
79
+ );
80
+ }
@@ -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
+ });