@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.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +14 -2
- package/src/App.export.test.tsx +1 -2
- package/src/App.plan-review.test.tsx +16 -0
- package/src/App.submit-confirm.test.tsx +34 -5
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +4 -0
- package/src/App.walk.test.tsx +2 -2
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +7 -3
- package/src/app-view-model.ts +3 -0
- 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.tsx +11 -12
- package/src/components/DiffContentView.tsx +4 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/ManageAccessDialog.tsx +13 -11
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/ShareChoiceDialog.tsx +0 -2
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadTree.tsx +11 -6
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -1
- package/src/components/ThreadView.tsx +66 -7
- package/src/components/Toast.tsx +0 -5
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
- package/src/components/primitives/Card.tsx +3 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +8 -1
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -1
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/run.ts +31 -19
- package/src/serve.ts +1 -1
- package/src/share.ts +1 -1
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.switch-cache.test.ts +146 -0
- package/src/thread-controller.ts +157 -24
- 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 {
|
|
14
|
-
import {
|
|
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={
|
|
102
|
-
|
|
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
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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 = "
|
|
32
|
-
cancelLabel = "
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
598
|
+
intralineResolver.runsForRow(rowIndex),
|
|
599
599
|
syntaxByRow.get(rowIndex),
|
|
600
600
|
tokens,
|
|
601
601
|
);
|
|
602
602
|
const emphasisBgByColumn = emphasisBackgroundColumns(
|
|
603
603
|
text,
|
|
604
|
-
|
|
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
|
|
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
|
-
<
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
</
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
</
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
</
|
|
116
|
-
</
|
|
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, "
|
|
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, "
|
|
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
|
|
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
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,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 } =
|
|
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
|
+
});
|
|
@@ -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
|
});
|