@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
|
@@ -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
|
}
|
|
@@ -7,12 +7,12 @@ export const meta: StoryMeta = { title: "Overlays/ConfirmDialog" };
|
|
|
7
7
|
|
|
8
8
|
const callbacks = { onConfirm: () => {}, onCancel: () => {} };
|
|
9
9
|
|
|
10
|
-
export const
|
|
10
|
+
export const DeleteThread: Story = {
|
|
11
11
|
render: () => (
|
|
12
12
|
<ConfirmDialog
|
|
13
13
|
isOpen
|
|
14
|
-
title="
|
|
15
|
-
message={'Delete "Auth rollout"? This removes the
|
|
14
|
+
title=" delete thread "
|
|
15
|
+
message={'Delete "Auth rollout"? This removes the thread.'}
|
|
16
16
|
{...callbacks}
|
|
17
17
|
/>
|
|
18
18
|
),
|
|
@@ -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";
|
|
@@ -26,6 +26,7 @@ import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../mark-runs
|
|
|
26
26
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
27
27
|
import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
28
28
|
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
29
|
+
import { NavModeHint } from "./NavModeHint";
|
|
29
30
|
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
30
31
|
import { coloredRowSpans } from "./diff-content-view-layout";
|
|
31
32
|
|
|
@@ -97,8 +98,7 @@ export interface DiffContentViewProps {
|
|
|
97
98
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
98
99
|
/** The author's display name for a comment's hover tooltip. */
|
|
99
100
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
100
|
-
|
|
101
|
-
onLeaderCommand?: (key: KeyEvent) => void;
|
|
101
|
+
onNavCommand?: (key: KeyEvent) => boolean;
|
|
102
102
|
onExit: () => void;
|
|
103
103
|
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
104
104
|
rejectedRows?: Set<number>;
|
|
@@ -385,7 +385,8 @@ function foregroundColumns(
|
|
|
385
385
|
const columns: string[] = [];
|
|
386
386
|
|
|
387
387
|
for (const span of coloredRowSpans(text, intraline, syntax, baseColor, tokens)) {
|
|
388
|
-
|
|
388
|
+
// one color per code unit, matching the column model that indexes text by .length
|
|
389
|
+
columns.push(...Array.from({ length: span.text.length }, () => span.foreground));
|
|
389
390
|
}
|
|
390
391
|
|
|
391
392
|
return columns;
|
|
@@ -492,8 +493,7 @@ export function DiffContentView({
|
|
|
492
493
|
onReply,
|
|
493
494
|
onUpdateAnnotation,
|
|
494
495
|
resolveAuthorLabel,
|
|
495
|
-
|
|
496
|
-
onLeaderCommand,
|
|
496
|
+
onNavCommand,
|
|
497
497
|
onExit,
|
|
498
498
|
rejectedRows = EMPTY_REJECTED,
|
|
499
499
|
fold,
|
|
@@ -528,14 +528,13 @@ export function DiffContentView({
|
|
|
528
528
|
onReply,
|
|
529
529
|
onUpdateAnnotation,
|
|
530
530
|
resolveAuthorLabel,
|
|
531
|
-
|
|
532
|
-
onLeaderCommand,
|
|
531
|
+
onNavCommand,
|
|
533
532
|
onExit,
|
|
534
533
|
});
|
|
535
534
|
const { palette } = surface;
|
|
536
535
|
const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
|
|
537
536
|
const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
|
|
538
|
-
const
|
|
537
|
+
const intralineResolver = useMemo(() => createIntralineResolver(rows), [rows]);
|
|
539
538
|
const syntaxByRow = useSyntaxHighlights(rows);
|
|
540
539
|
const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
|
|
541
540
|
const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
|
|
@@ -595,13 +594,13 @@ export function DiffContentView({
|
|
|
595
594
|
const fgByColumn = foregroundColumns(
|
|
596
595
|
text,
|
|
597
596
|
rowBaseColor(row, tokens),
|
|
598
|
-
|
|
597
|
+
intralineResolver.runsForRow(rowIndex),
|
|
599
598
|
syntaxByRow.get(rowIndex),
|
|
600
599
|
tokens,
|
|
601
600
|
);
|
|
602
601
|
const emphasisBgByColumn = emphasisBackgroundColumns(
|
|
603
602
|
text,
|
|
604
|
-
|
|
603
|
+
intralineResolver.runsForRow(rowIndex),
|
|
605
604
|
rowEmphasisBackground(row, tokens),
|
|
606
605
|
);
|
|
607
606
|
const rowBg = rowBackground(row, tokens);
|
|
@@ -856,10 +855,18 @@ export function DiffContentView({
|
|
|
856
855
|
>
|
|
857
856
|
{materialized}
|
|
858
857
|
</scrollbox>
|
|
858
|
+
{suspended ? null : (
|
|
859
|
+
<NavModeHint
|
|
860
|
+
navMode={surface.navMode}
|
|
861
|
+
surface={onNavCommand ? "diff" : "bare"}
|
|
862
|
+
theme={tokens}
|
|
863
|
+
/>
|
|
864
|
+
)}
|
|
859
865
|
</box>
|
|
860
866
|
<DiscussionMarkerRail
|
|
861
867
|
discussions={surface.discussions}
|
|
862
868
|
spanQuote={surface.spanQuote}
|
|
869
|
+
focusedKey={surface.focusedDiscussion}
|
|
863
870
|
onJump={surface.jumpToDiscussion}
|
|
864
871
|
scrollbox={scrollRef}
|
|
865
872
|
theme={theme}
|
|
@@ -25,12 +25,14 @@ interface HoveredMarker {
|
|
|
25
25
|
function ScrollMarkers({
|
|
26
26
|
discussions,
|
|
27
27
|
hovered,
|
|
28
|
+
focused,
|
|
28
29
|
tokens,
|
|
29
30
|
onHover,
|
|
30
31
|
onJump,
|
|
31
32
|
}: {
|
|
32
33
|
discussions: Discussion[];
|
|
33
34
|
hovered: string | null;
|
|
35
|
+
focused: string | null;
|
|
34
36
|
tokens: Theme;
|
|
35
37
|
onHover: (marker: HoveredMarker | null) => void;
|
|
36
38
|
onJump: (key: string) => void;
|
|
@@ -50,8 +52,10 @@ function ScrollMarkers({
|
|
|
50
52
|
return index >= 0 && index < discussions.length ? index : null;
|
|
51
53
|
};
|
|
52
54
|
const hoveredIndex = discussions.findIndex((discussion) => discussion.key === hovered);
|
|
55
|
+
const focusedIndex = discussions.findIndex((discussion) => discussion.key === focused);
|
|
56
|
+
const isActive = (index: number): boolean => index === hoveredIndex || index === focusedIndex;
|
|
53
57
|
const colorFor = (index: number): string => {
|
|
54
|
-
if (index
|
|
58
|
+
if (isActive(index)) return tokens.text;
|
|
55
59
|
if (hoveredIndex >= 0 && discussions.length >= 5 && Math.abs(index - hoveredIndex) === 1) {
|
|
56
60
|
return tokens.textMuted;
|
|
57
61
|
}
|
|
@@ -92,7 +96,7 @@ function ScrollMarkers({
|
|
|
92
96
|
style={{ position: "absolute", top: rowFor(index), left: 0 }}
|
|
93
97
|
fg={colorFor(index)}
|
|
94
98
|
>
|
|
95
|
-
{(index
|
|
99
|
+
{(isActive(index) ? "●" : "○").padStart(2)}
|
|
96
100
|
</text>
|
|
97
101
|
))}
|
|
98
102
|
</box>
|
|
@@ -101,10 +105,9 @@ function ScrollMarkers({
|
|
|
101
105
|
|
|
102
106
|
export interface DiscussionMarkerRailProps {
|
|
103
107
|
discussions: Discussion[];
|
|
104
|
-
/** The text a discussion's span covers, for the hover preview. */
|
|
105
108
|
spanQuote: (span: TextSpan) => string;
|
|
109
|
+
focusedKey?: string | null;
|
|
106
110
|
onJump: (key: string) => void;
|
|
107
|
-
/** The surface's scrollbox; when given, its scrollbar draws past the dots as the panel's rightmost column. */
|
|
108
111
|
scrollbox?: RefObject<ScrollBoxRenderable | null>;
|
|
109
112
|
theme?: Theme;
|
|
110
113
|
}
|
|
@@ -112,6 +115,7 @@ export interface DiscussionMarkerRailProps {
|
|
|
112
115
|
export function DiscussionMarkerRail({
|
|
113
116
|
discussions,
|
|
114
117
|
spanQuote,
|
|
118
|
+
focusedKey = null,
|
|
115
119
|
onJump,
|
|
116
120
|
scrollbox,
|
|
117
121
|
theme,
|
|
@@ -171,6 +175,7 @@ export function DiscussionMarkerRail({
|
|
|
171
175
|
<ScrollMarkers
|
|
172
176
|
discussions={discussions}
|
|
173
177
|
hovered={hovered?.key ?? null}
|
|
178
|
+
focused={focusedKey}
|
|
174
179
|
tokens={tokens}
|
|
175
180
|
onHover={setHovered}
|
|
176
181
|
onJump={onJump}
|
|
@@ -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.
|
|
@@ -35,8 +35,7 @@ export type DiffSurfaceProps = Pick<
|
|
|
35
35
|
| "onReply"
|
|
36
36
|
| "onUpdateAnnotation"
|
|
37
37
|
| "resolveAuthorLabel"
|
|
38
|
-
| "
|
|
39
|
-
| "onLeaderCommand"
|
|
38
|
+
| "onNavCommand"
|
|
40
39
|
| "onExit"
|
|
41
40
|
>;
|
|
42
41
|
|
|
@@ -49,6 +48,8 @@ export function ChangesTabBody(props: {
|
|
|
49
48
|
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
50
49
|
split?: boolean;
|
|
51
50
|
dimmed: boolean;
|
|
51
|
+
/** Rows the sibling Thread pane spends on its footer, so this empty hint centers level with it. */
|
|
52
|
+
emptyBottomPadding?: number;
|
|
52
53
|
theme: Theme;
|
|
53
54
|
}): React.ReactNode {
|
|
54
55
|
const session = props.surface.session;
|
|
@@ -65,6 +66,7 @@ export function ChangesTabBody(props: {
|
|
|
65
66
|
flexDirection: "column",
|
|
66
67
|
alignItems: "center",
|
|
67
68
|
justifyContent: "center",
|
|
69
|
+
paddingBottom: props.emptyBottomPadding,
|
|
68
70
|
}}
|
|
69
71
|
>
|
|
70
72
|
<text fg={props.theme.textDim}>No changes</text>
|
|
@@ -99,6 +101,8 @@ export function GridTabContent(props: {
|
|
|
99
101
|
readFile: (path: string) => Promise<string | null>;
|
|
100
102
|
/** Persist a comment on a project file; the file view built the anchor against its own lines. */
|
|
101
103
|
onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
|
|
104
|
+
/** Rows the sibling Thread pane spends on its footer, so the empty hint centers level with it. */
|
|
105
|
+
emptyBottomPadding?: number;
|
|
102
106
|
theme: Theme;
|
|
103
107
|
}): React.ReactNode {
|
|
104
108
|
const { tab } = props;
|
|
@@ -138,6 +142,7 @@ export function GridTabContent(props: {
|
|
|
138
142
|
fileStats={props.fileStats}
|
|
139
143
|
split={props.split}
|
|
140
144
|
dimmed={props.dimmed}
|
|
145
|
+
emptyBottomPadding={props.emptyBottomPadding}
|
|
141
146
|
theme={props.theme}
|
|
142
147
|
/>
|
|
143
148
|
);
|
|
@@ -170,6 +175,7 @@ export function GridTabContent(props: {
|
|
|
170
175
|
fileStats={props.fileStats}
|
|
171
176
|
split={props.split}
|
|
172
177
|
dimmed={props.dimmed}
|
|
178
|
+
emptyBottomPadding={props.emptyBottomPadding}
|
|
173
179
|
theme={props.theme}
|
|
174
180
|
/>
|
|
175
181
|
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { HandleChipsInput } from "./HandleChipsInput";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Inputs/HandleChips" };
|
|
7
|
+
|
|
8
|
+
export const WithHandles: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<box style={{ padding: 1, backgroundColor: DARK.elevated }}>
|
|
11
|
+
<HandleChipsInput logins={["octocat", "hubot"]} onChange={() => {}} theme={DARK} />
|
|
12
|
+
</box>
|
|
13
|
+
),
|
|
14
|
+
size: { width: 48, height: 6 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Empty: Story = {
|
|
18
|
+
render: () => (
|
|
19
|
+
<box style={{ padding: 1, backgroundColor: DARK.elevated }}>
|
|
20
|
+
<HandleChipsInput logins={[]} onChange={() => {}} theme={DARK} />
|
|
21
|
+
</box>
|
|
22
|
+
),
|
|
23
|
+
size: { width: 48, height: 6 },
|
|
24
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Backspace on the empty field pulls the last chip back in to edit, rather than deleting it. */
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { DARK } from "../theme";
|
|
7
|
+
import { press, settle } from "../test-support";
|
|
8
|
+
import { HandleChipsInput } from "./HandleChipsInput";
|
|
9
|
+
|
|
10
|
+
/** A controlled host so onChange edits flow back into the rendered list, like the wizard. */
|
|
11
|
+
function Harness({ initial }: { initial: string[] }): React.ReactNode {
|
|
12
|
+
const [logins, setLogins] = useState(initial);
|
|
13
|
+
|
|
14
|
+
return <HandleChipsInput logins={logins} onChange={setLogins} theme={DARK} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("HandleChipsInput", () => {
|
|
18
|
+
test("backspace on the empty field restores the last chip as editable text", async () => {
|
|
19
|
+
// Arrange - one committed chip and an empty input showing its placeholder
|
|
20
|
+
const setup = await testRender(<Harness initial={["octocat"]} />, { width: 48, height: 6 });
|
|
21
|
+
|
|
22
|
+
await settle(setup);
|
|
23
|
+
expect(setup.captureCharFrame()).toContain("handle");
|
|
24
|
+
|
|
25
|
+
// Act - backspace with the input empty
|
|
26
|
+
await press(setup, "backspace");
|
|
27
|
+
|
|
28
|
+
// Assert - the handle is back in the field (placeholder gone), not deleted
|
|
29
|
+
const frame = setup.captureCharFrame();
|
|
30
|
+
|
|
31
|
+
expect(frame).toContain("octocat");
|
|
32
|
+
expect(frame).not.toContain("handle");
|
|
33
|
+
setup.renderer.destroy();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tag-style input for GitHub logins: each added handle is an inline chip with
|
|
3
|
+
* a hover × to remove, and a trailing one-line field where enter adds the next
|
|
4
|
+
* and backspace on an empty field drops the last. Controlled - the caller owns
|
|
5
|
+
* the list and receives every change.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { useKeyboard } 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 { NERD } from "./primitives/icons";
|
|
14
|
+
|
|
15
|
+
// enter adds the handle rather than inserting a newline into the one-line field
|
|
16
|
+
const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
17
|
+
|
|
18
|
+
function HandleChip({
|
|
19
|
+
login,
|
|
20
|
+
onRemove,
|
|
21
|
+
tokens,
|
|
22
|
+
}: {
|
|
23
|
+
login: string;
|
|
24
|
+
onRemove: () => void;
|
|
25
|
+
tokens: Theme;
|
|
26
|
+
}): React.ReactNode {
|
|
27
|
+
const [hovered, setHovered] = useState(false);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<box
|
|
31
|
+
onMouseOver={() => setHovered(true)}
|
|
32
|
+
onMouseOut={() => setHovered(false)}
|
|
33
|
+
style={{
|
|
34
|
+
flexDirection: "row",
|
|
35
|
+
flexShrink: 0,
|
|
36
|
+
marginRight: 2,
|
|
37
|
+
paddingLeft: 1,
|
|
38
|
+
backgroundColor: tokens.border,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<text fg={tokens.text} style={{ wrapMode: "none" }}>{`@${login}`}</text>
|
|
42
|
+
<box
|
|
43
|
+
onMouseUp={(event) => {
|
|
44
|
+
event.stopPropagation();
|
|
45
|
+
onRemove();
|
|
46
|
+
}}
|
|
47
|
+
style={{ paddingLeft: 1, paddingRight: 1 }}
|
|
48
|
+
>
|
|
49
|
+
<text fg={tokens.textDim}>{hovered ? NERD.close : " "}</text>
|
|
50
|
+
</box>
|
|
51
|
+
</box>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HandleChipsInputProps {
|
|
56
|
+
logins: string[];
|
|
57
|
+
onChange: (logins: string[]) => void;
|
|
58
|
+
/** Whether this field owns the cursor and backspace; false shows the chips without a blinking caret. */
|
|
59
|
+
focused?: boolean;
|
|
60
|
+
/** Fired on backspace when the field is empty and no chips remain, so the caller can collapse the section. */
|
|
61
|
+
onEmptyBackspace?: () => void;
|
|
62
|
+
theme?: Theme;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function HandleChipsInput({
|
|
66
|
+
logins,
|
|
67
|
+
onChange,
|
|
68
|
+
focused = true,
|
|
69
|
+
onEmptyBackspace,
|
|
70
|
+
theme,
|
|
71
|
+
}: HandleChipsInputProps): React.ReactNode {
|
|
72
|
+
const tokens = useComponentTheme(theme);
|
|
73
|
+
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
74
|
+
// the text the current field generation mounts with; a remount reseeds it
|
|
75
|
+
const [seed, setSeed] = useState("");
|
|
76
|
+
const [inputGeneration, setInputGeneration] = useState(0);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const input = inputRef.current;
|
|
80
|
+
|
|
81
|
+
if (!input || !focused) return;
|
|
82
|
+
input.focus();
|
|
83
|
+
input.cursorOffset = seed.length; // land the caret at the end of the seeded text
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, [inputGeneration, focused]);
|
|
86
|
+
|
|
87
|
+
const reseed = (text: string): void => {
|
|
88
|
+
setSeed(text);
|
|
89
|
+
setInputGeneration((generation) => generation + 1);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const addDraft = (): void => {
|
|
93
|
+
const handle = (inputRef.current?.plainText ?? "").trim().replace(/^@/, "").toLowerCase();
|
|
94
|
+
|
|
95
|
+
if (handle && !logins.includes(handle)) onChange([...logins, handle]);
|
|
96
|
+
reseed("");
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
useKeyboard((key) => {
|
|
100
|
+
if (!focused) return;
|
|
101
|
+
if (key.name !== "backspace" || (inputRef.current?.plainText ?? "").length > 0) return;
|
|
102
|
+
// backspace on an empty field pulls the last chip back in to edit; with none left, collapse
|
|
103
|
+
if (logins.length > 0) {
|
|
104
|
+
const last = logins[logins.length - 1]!;
|
|
105
|
+
|
|
106
|
+
onChange(logins.slice(0, -1));
|
|
107
|
+
reseed(last);
|
|
108
|
+
} else {
|
|
109
|
+
onEmptyBackspace?.();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<box style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center" }}>
|
|
115
|
+
{logins.map((login) => (
|
|
116
|
+
<HandleChip
|
|
117
|
+
key={login}
|
|
118
|
+
login={login}
|
|
119
|
+
onRemove={() => onChange(logins.filter((entry) => entry !== login))}
|
|
120
|
+
tokens={tokens}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
124
|
+
<text fg={tokens.textDim}>{"@"}</text>
|
|
125
|
+
<textarea
|
|
126
|
+
key={inputGeneration}
|
|
127
|
+
ref={inputRef}
|
|
128
|
+
focused
|
|
129
|
+
initialValue={seed}
|
|
130
|
+
placeholder="handle"
|
|
131
|
+
keyBindings={ADD_KEY_BINDINGS}
|
|
132
|
+
onSubmit={addDraft}
|
|
133
|
+
style={{
|
|
134
|
+
height: 1,
|
|
135
|
+
width: 16,
|
|
136
|
+
backgroundColor: tokens.elevated,
|
|
137
|
+
focusedBackgroundColor: tokens.elevated,
|
|
138
|
+
textColor: tokens.text,
|
|
139
|
+
focusedTextColor: tokens.text,
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
</box>
|
|
143
|
+
</box>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import { buildDisplay, type DisplayBlock } from "../view-plan";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { MarkdownGridBlock } from "./MarkdownGridBlock";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Surfaces/MarkdownGridBlock" };
|
|
8
|
+
|
|
9
|
+
/** The first display block of a kind, for a story that renders one grid in isolation. */
|
|
10
|
+
function firstBlock(markdown: string, kind: DisplayBlock["kind"]): DisplayBlock {
|
|
11
|
+
return buildDisplay(markdown).find((block) => block.kind === kind)!;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TABLE = firstBlock(
|
|
15
|
+
"| Name | Size |\n| :--- | ---: |\n| daemon | 12 |\n| client | 7 |",
|
|
16
|
+
"table",
|
|
17
|
+
);
|
|
18
|
+
const FRONTMATTER = firstBlock(
|
|
19
|
+
"---\ntitle: Migration Plan\nowner: platform team\nstatus: in review\n---\n\n# Plan\n",
|
|
20
|
+
"frontmatter",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const Table: Story = {
|
|
24
|
+
render: () => <MarkdownGridBlock block={TABLE} theme={DARK} contentWidth={60} />,
|
|
25
|
+
expectedColors: [DARK.text, DARK.textDim],
|
|
26
|
+
size: { width: 62, height: 6 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Frontmatter: Story = {
|
|
30
|
+
render: () => <MarkdownGridBlock block={FRONTMATTER} theme={DARK} contentWidth={60} />,
|
|
31
|
+
expectedColors: [DARK.text, DARK.textDim],
|
|
32
|
+
size: { width: 62, height: 9 },
|
|
33
|
+
};
|