@cueloop/client 0.1.0-alpha.78 → 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.diff.test.tsx +9 -4
- 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 +75 -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 +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- 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/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- 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/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- 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.ts +0 -1
- 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/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- 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.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- 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 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- 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
package/src/App.walk.test.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { DARK } from "./theme";
|
|
|
12
12
|
import {
|
|
13
13
|
isolateUserConfig,
|
|
14
14
|
press,
|
|
15
|
+
navCommand,
|
|
15
16
|
pressKey,
|
|
16
17
|
renderReadyApp,
|
|
17
18
|
waitForState,
|
|
@@ -104,7 +105,7 @@ describe("the guided walk", () => {
|
|
|
104
105
|
const setup = await renderApp();
|
|
105
106
|
|
|
106
107
|
// Act
|
|
107
|
-
await
|
|
108
|
+
await navCommand(setup, "k");
|
|
108
109
|
|
|
109
110
|
// Assert
|
|
110
111
|
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
@@ -172,7 +173,7 @@ describe("the guided walk", () => {
|
|
|
172
173
|
// Arrange
|
|
173
174
|
const setup = await renderApp();
|
|
174
175
|
|
|
175
|
-
await
|
|
176
|
+
await navCommand(setup, "k");
|
|
176
177
|
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
177
178
|
await press(setup, "]");
|
|
178
179
|
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
@@ -186,7 +187,7 @@ describe("the guided walk", () => {
|
|
|
186
187
|
expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
|
|
187
188
|
|
|
188
189
|
// Act
|
|
189
|
-
await
|
|
190
|
+
await navCommand(setup, "k");
|
|
190
191
|
|
|
191
192
|
// Assert
|
|
192
193
|
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
@@ -196,7 +197,7 @@ describe("the guided walk", () => {
|
|
|
196
197
|
// Arrange
|
|
197
198
|
const first = await renderApp();
|
|
198
199
|
|
|
199
|
-
await
|
|
200
|
+
await navCommand(first, "k");
|
|
200
201
|
await waitForText(first, "file 1 of 3 · 0 viewed");
|
|
201
202
|
await press(first, "]");
|
|
202
203
|
await press(first, "]");
|
|
@@ -211,7 +212,7 @@ describe("the guided walk", () => {
|
|
|
211
212
|
// a fresh client reads the viewed set back from the session record
|
|
212
213
|
const second = await renderApp();
|
|
213
214
|
|
|
214
|
-
await
|
|
215
|
+
await navCommand(second, "k");
|
|
215
216
|
|
|
216
217
|
// Assert
|
|
217
218
|
await waitForText(second, "file 3 of 3 · 2 viewed");
|
|
@@ -232,7 +233,7 @@ describe("the guided walk", () => {
|
|
|
232
233
|
const setup = await renderApp();
|
|
233
234
|
|
|
234
235
|
// Act
|
|
235
|
-
await
|
|
236
|
+
await navCommand(setup, "k");
|
|
236
237
|
|
|
237
238
|
// Assert
|
|
238
239
|
await waitForText(setup, "file 1 of 3");
|
|
@@ -273,7 +274,7 @@ describe("the guided walk", () => {
|
|
|
273
274
|
expect(foregroundsOf(setup, "new")).toContain(DARK.insertedForeground);
|
|
274
275
|
|
|
275
276
|
// Act
|
|
276
|
-
await
|
|
277
|
+
await navCommand(setup, "k");
|
|
277
278
|
|
|
278
279
|
// Assert
|
|
279
280
|
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
@@ -289,7 +290,7 @@ describe("the guided walk", () => {
|
|
|
289
290
|
const setup = await renderApp();
|
|
290
291
|
|
|
291
292
|
// Act - renderApp already waited for the diff to load; the session is resolved
|
|
292
|
-
await
|
|
293
|
+
await navCommand(setup, "k");
|
|
293
294
|
|
|
294
295
|
// Assert
|
|
295
296
|
await waitForText(setup, "review submitted - read-only");
|
|
@@ -21,10 +21,12 @@ import {
|
|
|
21
21
|
dragText,
|
|
22
22
|
isolateUserConfig,
|
|
23
23
|
locateText,
|
|
24
|
+
press,
|
|
24
25
|
pressKey,
|
|
25
26
|
typeText,
|
|
26
27
|
waitForState,
|
|
27
28
|
waitForText,
|
|
29
|
+
waitForTextGone,
|
|
28
30
|
renderReadyApp,
|
|
29
31
|
} from "./test-support";
|
|
30
32
|
import { NERD } from "./components/primitives/icons";
|
|
@@ -98,6 +100,7 @@ type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
|
98
100
|
|
|
99
101
|
/** The header controls sit on the top content row of the compact header. */
|
|
100
102
|
const HEADER_ROW = 0;
|
|
103
|
+
const BARE_NAV_HINT = "c comment · z fold · type to leave";
|
|
101
104
|
|
|
102
105
|
/** Column of a Project-header toggle, located by its own glyph so spacing changes never break the click. */
|
|
103
106
|
function toggleColumn(setup: Setup, glyph: string): number {
|
|
@@ -496,6 +499,89 @@ describe("the bare-launch welcome shell", () => {
|
|
|
496
499
|
expect(frame).toContain("edited in the working tree");
|
|
497
500
|
});
|
|
498
501
|
|
|
502
|
+
test("nav mode on the Welcome tab offers only comment and fold; a diff letter types", async () => {
|
|
503
|
+
const setup = await renderReadyApp(
|
|
504
|
+
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
|
|
505
|
+
{ width: 160, height: 28 },
|
|
506
|
+
);
|
|
507
|
+
await waitForText(setup, "quick brown fox");
|
|
508
|
+
const line = locateText(setup, "quick brown fox");
|
|
509
|
+
await setup.mockMouse.click(line.column, line.row);
|
|
510
|
+
|
|
511
|
+
// esc enters nav; the footer names what this surface can do, not the diff commands
|
|
512
|
+
await press(setup, "escape");
|
|
513
|
+
await waitForText(setup, BARE_NAV_HINT);
|
|
514
|
+
expect(setup.captureCharFrame()).not.toContain("x reject");
|
|
515
|
+
|
|
516
|
+
// a diff letter has no command here, so it leaves nav and types, as the footer says
|
|
517
|
+
await pressKey(setup, "d");
|
|
518
|
+
await waitForText(setup, "● d");
|
|
519
|
+
await press(setup, "escape");
|
|
520
|
+
await waitForTextGone(setup, "● d");
|
|
521
|
+
|
|
522
|
+
// c opens a comment on the caret word; z folds the saved card
|
|
523
|
+
await press(setup, "escape");
|
|
524
|
+
await waitForText(setup, BARE_NAV_HINT);
|
|
525
|
+
await pressKey(setup, "c");
|
|
526
|
+
await typeText(setup, "note");
|
|
527
|
+
await waitForText(setup, "● note");
|
|
528
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
529
|
+
await waitForText(setup, "note");
|
|
530
|
+
await press(setup, "escape");
|
|
531
|
+
await waitForText(setup, BARE_NAV_HINT);
|
|
532
|
+
await pressKey(setup, "z");
|
|
533
|
+
await waitForText(setup, "○ 1 comment ›");
|
|
534
|
+
|
|
535
|
+
setup.renderer.destroy();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("nav mode on a bare changed-file diff has no reject or walk; x types instead", async () => {
|
|
539
|
+
const setup = await renderReadyApp(
|
|
540
|
+
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
|
|
541
|
+
{ width: 160, height: 28 },
|
|
542
|
+
);
|
|
543
|
+
await waitForText(setup, "README.md");
|
|
544
|
+
const readme = locateText(setup, "README.md");
|
|
545
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
546
|
+
await waitForText(setup, "edited in the working tree");
|
|
547
|
+
const line = locateText(setup, "edited in the working tree");
|
|
548
|
+
await setup.mockMouse.click(line.column, line.row);
|
|
549
|
+
|
|
550
|
+
await press(setup, "escape");
|
|
551
|
+
await waitForText(setup, BARE_NAV_HINT);
|
|
552
|
+
expect(setup.captureCharFrame()).not.toContain("x reject");
|
|
553
|
+
await pressKey(setup, "x");
|
|
554
|
+
await waitForText(setup, "● x");
|
|
555
|
+
const frame = setup.captureCharFrame();
|
|
556
|
+
expect(frame).toContain("edited in the working tree");
|
|
557
|
+
expect(frame).not.toContain("change rejected");
|
|
558
|
+
|
|
559
|
+
setup.renderer.destroy();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("nav mode on a project file offers only comment and fold", async () => {
|
|
563
|
+
const setup = await renderReadyApp(
|
|
564
|
+
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
|
|
565
|
+
{ width: 160, height: 28 },
|
|
566
|
+
);
|
|
567
|
+
await waitForText(setup, "README.md");
|
|
568
|
+
await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
|
|
569
|
+
await waitForText(setup, "README.md");
|
|
570
|
+
const readme = locateText(setup, "README.md");
|
|
571
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
572
|
+
await waitForText(setup, "edited in the working tree");
|
|
573
|
+
const line = locateText(setup, "edited in the working tree");
|
|
574
|
+
await setup.mockMouse.click(line.column, line.row);
|
|
575
|
+
|
|
576
|
+
await press(setup, "escape");
|
|
577
|
+
await waitForText(setup, BARE_NAV_HINT);
|
|
578
|
+
expect(setup.captureCharFrame()).not.toContain("x reject");
|
|
579
|
+
await pressKey(setup, "d");
|
|
580
|
+
await waitForText(setup, "● d");
|
|
581
|
+
|
|
582
|
+
setup.renderer.destroy();
|
|
583
|
+
});
|
|
584
|
+
|
|
499
585
|
test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
|
|
500
586
|
const setup = await renderWelcome();
|
|
501
587
|
|
|
@@ -11,6 +11,7 @@ import type { Theme } from "./theme";
|
|
|
11
11
|
export const UNDERLINE = createTextAttributes({ underline: true });
|
|
12
12
|
export const CUT = createTextAttributes({ strikethrough: true, dim: true });
|
|
13
13
|
export const BOLD = createTextAttributes({ bold: true });
|
|
14
|
+
export const ITALIC = createTextAttributes({ italic: true });
|
|
14
15
|
|
|
15
16
|
/** The marked-words treatment: a violet backdrop under an underline. */
|
|
16
17
|
const MARK_BACKDROP_DARK = "#463852";
|
package/src/app-screens.tsx
CHANGED
|
@@ -371,12 +371,8 @@ export function NoThreadShell(props: {
|
|
|
371
371
|
{menuChrome}
|
|
372
372
|
<ConfirmDialog
|
|
373
373
|
isOpen={confirming !== null}
|
|
374
|
-
title="
|
|
375
|
-
message={
|
|
376
|
-
confirming
|
|
377
|
-
? `Delete "${confirming.title}"? This removes the plan and its review.`
|
|
378
|
-
: ""
|
|
379
|
-
}
|
|
374
|
+
title=" delete thread "
|
|
375
|
+
message={confirming ? `Delete "${confirming.title}"? This removes the thread.` : ""}
|
|
380
376
|
onConfirm={() => {
|
|
381
377
|
if (confirming) controller.deleteSession(confirming.sessionId);
|
|
382
378
|
setMode({ type: "normal" });
|
|
@@ -523,6 +519,16 @@ export function TrailingOverlays(props: {
|
|
|
523
519
|
theme={theme}
|
|
524
520
|
/>
|
|
525
521
|
) : null}
|
|
522
|
+
{mode.type === "confirmDelete" ? (
|
|
523
|
+
<ConfirmDialog
|
|
524
|
+
isOpen
|
|
525
|
+
title=" delete thread "
|
|
526
|
+
message={`Delete "${mode.title}"? This removes the thread.`}
|
|
527
|
+
onConfirm={() => dispatch({ type: "confirmDialog" })}
|
|
528
|
+
onCancel={() => setMode({ type: "normal" })}
|
|
529
|
+
theme={theme}
|
|
530
|
+
/>
|
|
531
|
+
) : null}
|
|
526
532
|
{mode.type === "nameSelf" ? (
|
|
527
533
|
<PromptDialog
|
|
528
534
|
isOpen
|
package/src/app-view-model.ts
CHANGED
|
@@ -58,7 +58,6 @@ export function resolveOverlay(
|
|
|
58
58
|
mode.type === "treePrompt"
|
|
59
59
|
)
|
|
60
60
|
return "prompt";
|
|
61
|
-
if (mode.type === "shareChoice") return "shareChoice";
|
|
62
61
|
if (mode.type === "spanActions") return "spanActions";
|
|
63
62
|
if (completionPhase === "prompt") return "completion-prompt";
|
|
64
63
|
if (completionPhase === "counting") return "completion-counting";
|
|
@@ -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
|
),
|
|
@@ -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,8 +528,7 @@ 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;
|
|
@@ -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}
|
|
@@ -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
|
+
};
|