@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.77
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.focus.test.ts +147 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.test.tsx +11 -8
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +648 -221
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +181 -123
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +12 -2
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +12 -1
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +12 -2
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +45 -1
- package/src/diff-intraline.ts +26 -5
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/share.ts +10 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +18 -3
- package/src/use-annotation-surface.tsx +32 -2
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +33 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ShareChoiceDialog } from "./ShareChoiceDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Dialogs/ShareChoice" };
|
|
7
|
+
|
|
8
|
+
export const PublicSelected: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<ShareChoiceDialog
|
|
11
|
+
isOpen
|
|
12
|
+
selectedIndex={0}
|
|
13
|
+
onPublicShare={() => {}}
|
|
14
|
+
onPrivateShare={() => {}}
|
|
15
|
+
onClose={() => {}}
|
|
16
|
+
theme={DARK}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
size: { width: 60, height: 12 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const PrivateSelected: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<ShareChoiceDialog
|
|
25
|
+
isOpen
|
|
26
|
+
selectedIndex={1}
|
|
27
|
+
onPublicShare={() => {}}
|
|
28
|
+
onPrivateShare={() => {}}
|
|
29
|
+
onClose={() => {}}
|
|
30
|
+
theme={DARK}
|
|
31
|
+
/>
|
|
32
|
+
),
|
|
33
|
+
size: { width: 60, height: 12 },
|
|
34
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { DARK } from "../theme";
|
|
5
|
+
import { clickText, settle } from "../test-support";
|
|
6
|
+
import { ShareChoiceDialog } from "./ShareChoiceDialog";
|
|
7
|
+
|
|
8
|
+
interface ShareCounts {
|
|
9
|
+
publicShares: number;
|
|
10
|
+
privateShares: number;
|
|
11
|
+
closes: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function renderDialog(selectedIndex = 0): Promise<{
|
|
15
|
+
setup: Awaited<ReturnType<typeof testRender>>;
|
|
16
|
+
counts: ShareCounts;
|
|
17
|
+
}> {
|
|
18
|
+
const counts: ShareCounts = { publicShares: 0, privateShares: 0, closes: 0 };
|
|
19
|
+
const setup = await testRender(
|
|
20
|
+
<box style={{ width: 60, height: 12 }}>
|
|
21
|
+
<ShareChoiceDialog
|
|
22
|
+
isOpen
|
|
23
|
+
selectedIndex={selectedIndex}
|
|
24
|
+
onPublicShare={() => {
|
|
25
|
+
counts.publicShares += 1;
|
|
26
|
+
}}
|
|
27
|
+
onPrivateShare={() => {
|
|
28
|
+
counts.privateShares += 1;
|
|
29
|
+
}}
|
|
30
|
+
onClose={() => {
|
|
31
|
+
counts.closes += 1;
|
|
32
|
+
}}
|
|
33
|
+
theme={DARK}
|
|
34
|
+
/>
|
|
35
|
+
</box>,
|
|
36
|
+
{ width: 60, height: 12 },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await settle(setup);
|
|
40
|
+
|
|
41
|
+
return { setup, counts };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("ShareChoiceDialog", () => {
|
|
45
|
+
test("offers both the public and private choice", async () => {
|
|
46
|
+
// Arrange + Act
|
|
47
|
+
const { setup } = await renderDialog();
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
const frame = setup.captureCharFrame();
|
|
51
|
+
|
|
52
|
+
expect(frame).toContain("public link");
|
|
53
|
+
expect(frame).toContain("private link");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("clicking the public choice publishes and closes", async () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
const { setup, counts } = await renderDialog();
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
await clickText(setup, "public link");
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(counts.publicShares).toBe(1);
|
|
65
|
+
expect(counts.privateShares).toBe(0);
|
|
66
|
+
expect(counts.closes).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("clicking the private choice opens manage-access and closes", async () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
const { setup, counts } = await renderDialog();
|
|
72
|
+
|
|
73
|
+
// Act
|
|
74
|
+
await clickText(setup, "private link");
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
expect(counts.privateShares).toBe(1);
|
|
78
|
+
expect(counts.publicShares).toBe(0);
|
|
79
|
+
expect(counts.closes).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("renders nothing while closed", async () => {
|
|
83
|
+
// Arrange + Act
|
|
84
|
+
const setup = await testRender(
|
|
85
|
+
<box style={{ width: 60, height: 12 }}>
|
|
86
|
+
<ShareChoiceDialog
|
|
87
|
+
isOpen={false}
|
|
88
|
+
selectedIndex={0}
|
|
89
|
+
onPublicShare={() => {}}
|
|
90
|
+
onPrivateShare={() => {}}
|
|
91
|
+
onClose={() => {}}
|
|
92
|
+
theme={DARK}
|
|
93
|
+
/>
|
|
94
|
+
</box>,
|
|
95
|
+
{ width: 60, height: 12 },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await settle(setup);
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(setup.captureCharFrame()).not.toContain("public link");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share public/private choice, as an app-level overlay the share shortcut
|
|
3
|
+
* opens from any view. Public publishes a link for anyone; private opens the
|
|
4
|
+
* manage-access allowlist surface. The header share button and the shortcut both
|
|
5
|
+
* open this one surface. Selection moves with the keyboard (owned by the App) or
|
|
6
|
+
* a pointer click on a row.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import { Dialog } from "./primitives/Dialog";
|
|
14
|
+
|
|
15
|
+
export type ShareChoice = "public" | "private";
|
|
16
|
+
|
|
17
|
+
interface ShareChoiceRow {
|
|
18
|
+
choice: ShareChoice;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const SHARE_CHOICES: ShareChoiceRow[] = [
|
|
23
|
+
{ choice: "public", label: "public link" },
|
|
24
|
+
{ choice: "private", label: "private link" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export interface ShareChoiceDialogProps {
|
|
28
|
+
isOpen: boolean;
|
|
29
|
+
/** The highlighted row; the App drives it from the shareChoice mode. */
|
|
30
|
+
selectedIndex: number;
|
|
31
|
+
/** Publish a public link and copy its connection line. */
|
|
32
|
+
onPublicShare: () => void;
|
|
33
|
+
/** Open the private-share manage-access allowlist surface. */
|
|
34
|
+
onPrivateShare: () => void;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
theme?: Theme;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ShareChoiceDialog({
|
|
40
|
+
isOpen,
|
|
41
|
+
selectedIndex,
|
|
42
|
+
onPublicShare,
|
|
43
|
+
onPrivateShare,
|
|
44
|
+
onClose,
|
|
45
|
+
theme,
|
|
46
|
+
}: ShareChoiceDialogProps): React.ReactNode {
|
|
47
|
+
const tokens = useComponentTheme(theme);
|
|
48
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
49
|
+
|
|
50
|
+
const pick = (choice: ShareChoice): void => {
|
|
51
|
+
onClose();
|
|
52
|
+
if (choice === "public") onPublicShare();
|
|
53
|
+
else onPrivateShare();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (!isOpen) return null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Dialog
|
|
60
|
+
isOpen
|
|
61
|
+
title=" Share "
|
|
62
|
+
width={Math.min(46, terminalWidth - 6)}
|
|
63
|
+
height={7}
|
|
64
|
+
background={tokens.elevated}
|
|
65
|
+
onDismiss={onClose}
|
|
66
|
+
theme={theme}
|
|
67
|
+
>
|
|
68
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
|
|
69
|
+
{SHARE_CHOICES.map((row, index) => (
|
|
70
|
+
<box key={row.choice} onMouseUp={() => pick(row.choice)} style={{ flexDirection: "row" }}>
|
|
71
|
+
<text fg={index === selectedIndex ? tokens.accent : tokens.text}>
|
|
72
|
+
{index === selectedIndex ? "› " : " "}
|
|
73
|
+
{row.label}
|
|
74
|
+
</text>
|
|
75
|
+
</box>
|
|
76
|
+
))}
|
|
77
|
+
<box style={{ flexGrow: 1 }} />
|
|
78
|
+
<text fg={tokens.textDim}>↑↓ choose · enter select · esc cancel</text>
|
|
79
|
+
</box>
|
|
80
|
+
</Dialog>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
/** The thread footer keeps its repo/branch context on one line: a long branch truncates to
|
|
2
|
-
* than wrapping and pushing the send control onto a second row. */
|
|
1
|
+
/** The thread footer keeps its repo/branch context on one line: a long branch truncates to an
|
|
2
|
+
* ellipsis rather than wrapping and pushing the send control onto a second row. */
|
|
3
3
|
|
|
4
4
|
import { test, expect } from "bun:test";
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { testRender } from "@opentui/react/test-utils";
|
|
7
7
|
import { ThreadFooter } from "./ThreadFooter";
|
|
8
|
+
import { settle } from "../test-support";
|
|
8
9
|
import { DARK } from "../theme";
|
|
9
10
|
|
|
10
11
|
test("a long branch truncates on one line and keeps the send control in place", async () => {
|
|
@@ -20,12 +21,16 @@ test("a long branch truncates on one line and keeps the send control in place",
|
|
|
20
21
|
</box>,
|
|
21
22
|
{ width: 60, height: 3 },
|
|
22
23
|
);
|
|
23
|
-
await setup
|
|
24
|
+
await settle(setup);
|
|
25
|
+
await settle(setup);
|
|
24
26
|
|
|
25
|
-
const
|
|
26
|
-
const footerRow =
|
|
27
|
+
const frame = setup.captureCharFrame();
|
|
28
|
+
const footerRow = frame.split("\n").find((line) => line.includes("cueloop"))!;
|
|
27
29
|
// the repo/branch context and the send control share the single footer row
|
|
28
30
|
expect(footerRow).toContain("send message");
|
|
29
|
-
// the branch is clipped, not wrapped
|
|
30
|
-
expect(
|
|
31
|
+
// the branch is clipped to an ellipsis, not wrapped onto a second row
|
|
32
|
+
expect(footerRow).toContain("…");
|
|
33
|
+
expect(frame).not.toContain("long-name");
|
|
34
|
+
|
|
35
|
+
setup.renderer.destroy();
|
|
31
36
|
});
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// Changes column when a zoom drops the thread column. It carries the repo/branch
|
|
3
3
|
// context on the left and a written, clickable "Send message" control on the right.
|
|
4
4
|
|
|
5
|
-
import React from "react";
|
|
5
|
+
import React, { useRef } from "react";
|
|
6
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
6
7
|
import { DARK, type Theme } from "../theme";
|
|
8
|
+
import { useFrameMeasure } from "../use-frame-measure";
|
|
9
|
+
import { truncateTitle } from "./truncate-title";
|
|
7
10
|
import { NERD } from "./primitives/icons";
|
|
8
11
|
|
|
9
12
|
export interface ThreadFooterProps {
|
|
@@ -15,6 +18,9 @@ export interface ThreadFooterProps {
|
|
|
15
18
|
theme?: Theme;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
const ICON_COLUMNS = 2; // folder glyph + its trailing space
|
|
22
|
+
const SEPARATOR = " / ";
|
|
23
|
+
|
|
18
24
|
export function ThreadFooter({
|
|
19
25
|
repo,
|
|
20
26
|
branch,
|
|
@@ -23,6 +29,14 @@ export function ThreadFooter({
|
|
|
23
29
|
theme,
|
|
24
30
|
}: ThreadFooterProps): React.ReactNode {
|
|
25
31
|
const tokens = theme ?? DARK;
|
|
32
|
+
const boxRef = useRef<BoxRenderable | null>(null);
|
|
33
|
+
const width = useFrameMeasure(
|
|
34
|
+
() => boxRef.current?.width ?? 0,
|
|
35
|
+
(left, right) => left === right,
|
|
36
|
+
0,
|
|
37
|
+
);
|
|
38
|
+
const branchBudget = width - ICON_COLUMNS - repo.length - SEPARATOR.length;
|
|
39
|
+
const clippedBranch = width > 0 ? truncateTitle(branch, Math.max(0, branchBudget)) : branch;
|
|
26
40
|
|
|
27
41
|
return (
|
|
28
42
|
<box
|
|
@@ -38,11 +52,11 @@ export function ThreadFooter({
|
|
|
38
52
|
borderColor: tokens.border,
|
|
39
53
|
}}
|
|
40
54
|
>
|
|
41
|
-
<box style={{ flexShrink: 1, minWidth: 0, marginRight: 2 }}>
|
|
42
|
-
<text
|
|
55
|
+
<box ref={boxRef} style={{ flexShrink: 1, minWidth: 0, marginRight: 2 }}>
|
|
56
|
+
<text wrapMode="none">
|
|
43
57
|
<span fg={tokens.blue}>{`${NERD.folderClosed} `}</span>
|
|
44
58
|
<span fg={tokens.textMuted}>{repo}</span>
|
|
45
|
-
<span fg={tokens.textDim}>{
|
|
59
|
+
<span fg={tokens.textDim}>{`${SEPARATOR}${clippedBranch}`}</span>
|
|
46
60
|
</text>
|
|
47
61
|
</box>
|
|
48
62
|
<box style={{ flexGrow: 1 }} />
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The sidebar thread tree: a
|
|
2
|
+
* The sidebar thread tree: a Starred / Projects / Threads listing. Selection stays
|
|
3
3
|
* with the keyboard grammar - the cursor indexes the flat thread order and this
|
|
4
4
|
* component renders the snapshot. A hovered or selected thread reveals a kebab
|
|
5
|
-
* that opens
|
|
6
|
-
* ellipsis rather than wrapping. App
|
|
5
|
+
* that opens a floating pin / rename / delete menu below the row (so the list
|
|
6
|
+
* never shifts); a long title clips to an ellipsis rather than wrapping. App
|
|
7
|
+
* supplies the surrounding chrome.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import React, { useState } from "react";
|
|
10
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
11
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
10
12
|
import type { Theme } from "../theme";
|
|
11
13
|
import { useComponentTheme } from "./theme-context";
|
|
14
|
+
import { useFrameMeasure } from "../use-frame-measure";
|
|
15
|
+
import { useRootOverlay } from "./RootOverlay";
|
|
16
|
+
import { useMenuControl } from "./menu-control";
|
|
12
17
|
import { IconButton } from "./primitives/IconButton";
|
|
13
18
|
import { NERD } from "./primitives/icons";
|
|
14
19
|
import { truncateTitle } from "./truncate-title";
|
|
@@ -19,7 +24,8 @@ export interface ThreadTreeProps {
|
|
|
19
24
|
cursor: number;
|
|
20
25
|
/** The open thread's id; highlights it instead of the cursor (the left sidebar case). */
|
|
21
26
|
activeId?: string;
|
|
22
|
-
|
|
27
|
+
focused?: boolean;
|
|
28
|
+
/** Ids of starred threads; a starred row carries the star glyph and the menu offers Unstar. */
|
|
23
29
|
pinnedIds?: ReadonlySet<string>;
|
|
24
30
|
/** The column width, so titles fade to fit one line. */
|
|
25
31
|
width?: number;
|
|
@@ -27,13 +33,15 @@ export interface ThreadTreeProps {
|
|
|
27
33
|
onSelect?: (sessionId: string) => void;
|
|
28
34
|
/** Ask to delete a thread (the menu's Delete, wired to the confirm dialog). */
|
|
29
35
|
onRequestDelete?: (id: string, title: string) => void;
|
|
30
|
-
/** Toggle a thread's
|
|
36
|
+
/** Toggle a thread's starred state (the menu's Star/Unstar). */
|
|
31
37
|
onPin?: (id: string) => void;
|
|
32
38
|
/** Rename a thread's title (the menu's Rename). */
|
|
33
39
|
onRename?: (id: string, title: string) => void;
|
|
34
40
|
theme?: Theme;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
const MENU_WIDTH = 12;
|
|
44
|
+
|
|
37
45
|
function ActionsMenu({
|
|
38
46
|
pinned,
|
|
39
47
|
onPin,
|
|
@@ -47,31 +55,28 @@ function ActionsMenu({
|
|
|
47
55
|
onDelete?: () => void;
|
|
48
56
|
tokens: Theme;
|
|
49
57
|
}): React.ReactNode {
|
|
50
|
-
const item = (
|
|
51
|
-
glyph: string,
|
|
52
|
-
label: string,
|
|
53
|
-
color: string,
|
|
54
|
-
onPick?: () => void,
|
|
55
|
-
): React.ReactNode =>
|
|
58
|
+
const item = (label: string, color: string, onPick?: () => void): React.ReactNode =>
|
|
56
59
|
onPick !== undefined ? (
|
|
57
|
-
<box onMouseUp={onPick} style={{
|
|
58
|
-
<text fg={color}>{
|
|
60
|
+
<box onMouseUp={onPick} style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
61
|
+
<text fg={color}>{label}</text>
|
|
59
62
|
</box>
|
|
60
63
|
) : null;
|
|
61
64
|
|
|
62
65
|
return (
|
|
63
66
|
<box
|
|
67
|
+
onMouseUp={(event) => event.stopPropagation()}
|
|
64
68
|
style={{
|
|
69
|
+
width: MENU_WIDTH,
|
|
65
70
|
flexDirection: "column",
|
|
66
|
-
|
|
71
|
+
border: true,
|
|
67
72
|
borderStyle: "single",
|
|
68
|
-
border: ["left"],
|
|
69
73
|
borderColor: tokens.border,
|
|
74
|
+
backgroundColor: tokens.elevated,
|
|
70
75
|
}}
|
|
71
76
|
>
|
|
72
|
-
{item(
|
|
73
|
-
{item(
|
|
74
|
-
{item(
|
|
77
|
+
{item(pinned ? "unstar" : "star", tokens.text, onPin)}
|
|
78
|
+
{item("rename", tokens.text, onRename)}
|
|
79
|
+
{item("delete", tokens.red, onDelete)}
|
|
75
80
|
</box>
|
|
76
81
|
);
|
|
77
82
|
}
|
|
@@ -94,13 +99,57 @@ interface ThreadRowProps {
|
|
|
94
99
|
function ThreadRow(props: ThreadRowProps): React.ReactNode {
|
|
95
100
|
const { title, selected, pinned, titleWidth, menuOpen, onToggleMenu, tokens, theme } = props;
|
|
96
101
|
const [hovered, setHovered] = useState(false);
|
|
102
|
+
const rowRef = useRef<BoxRenderable | null>(null);
|
|
103
|
+
const { setOverlay, clearOverlay } = useRootOverlay();
|
|
104
|
+
const anchor = useFrameMeasure(
|
|
105
|
+
() => ({
|
|
106
|
+
x: rowRef.current?.x ?? 0,
|
|
107
|
+
y: rowRef.current?.y ?? 0,
|
|
108
|
+
width: rowRef.current?.width ?? 0,
|
|
109
|
+
}),
|
|
110
|
+
(left, right) => left.x === right.x && left.y === right.y && left.width === right.width,
|
|
111
|
+
{ x: 0, y: 0, width: 0 },
|
|
112
|
+
menuOpen,
|
|
113
|
+
);
|
|
97
114
|
const clippedTitle = truncateTitle(title, titleWidth);
|
|
98
115
|
const hasActions =
|
|
99
116
|
props.onPin !== undefined || props.onRename !== undefined || props.onDelete !== undefined;
|
|
100
117
|
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!menuOpen) return;
|
|
120
|
+
// the full-screen box closes the menu on an outside click; the menu drops from under the kebab
|
|
121
|
+
setOverlay(
|
|
122
|
+
"thread-menu",
|
|
123
|
+
<box
|
|
124
|
+
onMouseUp={onToggleMenu}
|
|
125
|
+
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
|
|
126
|
+
>
|
|
127
|
+
<box
|
|
128
|
+
style={{
|
|
129
|
+
position: "absolute",
|
|
130
|
+
top: anchor.y + 1,
|
|
131
|
+
left: Math.max(0, anchor.x + anchor.width - MENU_WIDTH),
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<ActionsMenu
|
|
135
|
+
pinned={pinned}
|
|
136
|
+
onPin={props.onPin}
|
|
137
|
+
onRename={props.onRename}
|
|
138
|
+
onDelete={props.onDelete}
|
|
139
|
+
tokens={tokens}
|
|
140
|
+
/>
|
|
141
|
+
</box>
|
|
142
|
+
</box>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return () => clearOverlay("thread-menu");
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
}, [menuOpen, anchor, pinned, tokens]);
|
|
148
|
+
|
|
101
149
|
return (
|
|
102
150
|
<box style={{ flexDirection: "column" }} onMouseOut={() => setHovered(false)}>
|
|
103
151
|
<box
|
|
152
|
+
ref={rowRef}
|
|
104
153
|
onMouseUp={props.onSelect}
|
|
105
154
|
onMouseOver={() => setHovered(true)}
|
|
106
155
|
style={{
|
|
@@ -109,7 +158,7 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
|
|
|
109
158
|
}}
|
|
110
159
|
>
|
|
111
160
|
<text wrapMode="none">
|
|
112
|
-
<span fg={tokens.textDim}>{pinned ? ` ${NERD.
|
|
161
|
+
<span fg={tokens.textDim}>{pinned ? ` ${NERD.star} ` : " "}</span>
|
|
113
162
|
<span fg={selected ? tokens.accent : tokens.textMuted}>{clippedTitle}</span>
|
|
114
163
|
</text>
|
|
115
164
|
<box style={{ flexGrow: 1 }} />
|
|
@@ -123,15 +172,6 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
|
|
|
123
172
|
/>
|
|
124
173
|
) : null}
|
|
125
174
|
</box>
|
|
126
|
-
{menuOpen ? (
|
|
127
|
-
<ActionsMenu
|
|
128
|
-
pinned={pinned}
|
|
129
|
-
onPin={props.onPin}
|
|
130
|
-
onRename={props.onRename}
|
|
131
|
-
onDelete={props.onDelete}
|
|
132
|
-
tokens={tokens}
|
|
133
|
-
/>
|
|
134
|
-
) : null}
|
|
135
175
|
</box>
|
|
136
176
|
);
|
|
137
177
|
}
|
|
@@ -140,6 +180,7 @@ export function ThreadTree({
|
|
|
140
180
|
rows,
|
|
141
181
|
cursor,
|
|
142
182
|
activeId,
|
|
183
|
+
focused = false,
|
|
143
184
|
pinnedIds,
|
|
144
185
|
width = 30,
|
|
145
186
|
onSelect,
|
|
@@ -149,21 +190,21 @@ export function ThreadTree({
|
|
|
149
190
|
theme,
|
|
150
191
|
}: ThreadTreeProps): React.ReactNode {
|
|
151
192
|
const tokens = useComponentTheme(theme);
|
|
152
|
-
const
|
|
193
|
+
const menuControl = useMenuControl();
|
|
153
194
|
// leave room for the 3-char prefix, the border, and the hover kebab so a row
|
|
154
195
|
// never wraps: the title fades to fit whatever is left
|
|
155
196
|
const titleWidth = Math.max(8, width - 9);
|
|
156
|
-
const closeMenu = (): void =>
|
|
197
|
+
const closeMenu = (): void => menuControl.closeMenu();
|
|
157
198
|
|
|
158
199
|
return (
|
|
159
200
|
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1 }}>
|
|
160
201
|
{rows.length === 0 ? (
|
|
161
202
|
<text fg={tokens.textDim}>no threads</text>
|
|
162
203
|
) : (
|
|
163
|
-
rows.map((row) => {
|
|
204
|
+
rows.map((row, index) => {
|
|
164
205
|
if (row.kind === "section") {
|
|
165
206
|
return (
|
|
166
|
-
<text key={row.id} fg={tokens.textDim}>
|
|
207
|
+
<text key={row.id} fg={tokens.textDim} style={{ marginTop: index > 0 ? 1 : 0 }}>
|
|
167
208
|
{row.label}
|
|
168
209
|
</text>
|
|
169
210
|
);
|
|
@@ -185,11 +226,13 @@ export function ThreadTree({
|
|
|
185
226
|
<ThreadRow
|
|
186
227
|
key={row.id}
|
|
187
228
|
title={title}
|
|
188
|
-
selected={
|
|
229
|
+
selected={
|
|
230
|
+
focused || activeId === undefined ? row.selectionIndex === cursor : activeId === id
|
|
231
|
+
}
|
|
189
232
|
pinned={pinnedIds?.has(id) ?? false}
|
|
190
233
|
titleWidth={titleWidth}
|
|
191
|
-
menuOpen={
|
|
192
|
-
onToggleMenu={() =>
|
|
234
|
+
menuOpen={menuControl.openMenuId === `thread:${id}`}
|
|
235
|
+
onToggleMenu={() => menuControl.toggleMenu(`thread:${id}`)}
|
|
193
236
|
onSelect={onSelect ? () => onSelect(id) : undefined}
|
|
194
237
|
onPin={onPin ? () => (onPin(id), closeMenu()) : undefined}
|
|
195
238
|
onRename={onRename ? () => (onRename(id, title), closeMenu()) : undefined}
|
|
@@ -60,6 +60,42 @@ async function mountComposerWithSkills(skills: SlashItem[] = [], width = 72) {
|
|
|
60
60
|
|
|
61
61
|
const mountComposer = () => mountComposerWithSkills();
|
|
62
62
|
|
|
63
|
+
describe("the command leader", () => {
|
|
64
|
+
test("the leader chord then a letter runs a command instead of composing a comment", async () => {
|
|
65
|
+
const commands: string[] = [];
|
|
66
|
+
const display = buildDisplay(PLAN, undefined);
|
|
67
|
+
const setup = await testRender(
|
|
68
|
+
<ThreadView
|
|
69
|
+
session={fixturePlanSession({
|
|
70
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
71
|
+
})}
|
|
72
|
+
display={display}
|
|
73
|
+
marks={marksByDisplay([], display)}
|
|
74
|
+
quickActions={[]}
|
|
75
|
+
observer={false}
|
|
76
|
+
leaderCombos={["ctrl+g"]}
|
|
77
|
+
onLeaderCommand={(key) => commands.push(key.name)}
|
|
78
|
+
onAnnotate={() => {}}
|
|
79
|
+
onReply={() => {}}
|
|
80
|
+
onUpdateAnnotation={() => {}}
|
|
81
|
+
onExit={() => {}}
|
|
82
|
+
/>,
|
|
83
|
+
{ width: 72, height: 20 },
|
|
84
|
+
);
|
|
85
|
+
await settle(setup);
|
|
86
|
+
|
|
87
|
+
setup.mockInput.pressKey("g", { ctrl: true });
|
|
88
|
+
setup.mockInput.pressKey("x");
|
|
89
|
+
await settle(setup);
|
|
90
|
+
|
|
91
|
+
// the command fired and no composer opened (a bare "x" would have started a draft)
|
|
92
|
+
expect(commands).toEqual(["x"]);
|
|
93
|
+
expect(setup.captureCharFrame()).not.toContain("enter save");
|
|
94
|
+
|
|
95
|
+
setup.renderer.destroy();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
63
99
|
describe("skills in the palette", () => {
|
|
64
100
|
test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
|
|
65
101
|
const setup = await mountComposerWithSkills([
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import React, { useEffect, useRef } from "react";
|
|
16
|
-
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
17
|
-
import type { Thread } from "@cueloop/schema";
|
|
16
|
+
import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
|
|
17
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
18
18
|
import { displayText, type DisplayBlock, type Mark } from "../view-plan";
|
|
19
19
|
import type { TextSpan } from "../thread-selection";
|
|
20
20
|
import type { QuickAction } from "../config";
|
|
@@ -127,6 +127,10 @@ export interface ThreadViewProps {
|
|
|
127
127
|
onAnnotate: (span: TextSpan, body: string) => void;
|
|
128
128
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
129
129
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
130
|
+
/** The author's display name for a comment's hover tooltip. */
|
|
131
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
132
|
+
leaderCombos?: readonly string[];
|
|
133
|
+
onLeaderCommand?: (key: KeyEvent) => void;
|
|
130
134
|
onExit: () => void;
|
|
131
135
|
theme?: Theme;
|
|
132
136
|
}
|
|
@@ -148,6 +152,9 @@ export function ThreadView({
|
|
|
148
152
|
onAnnotate,
|
|
149
153
|
onReply,
|
|
150
154
|
onUpdateAnnotation,
|
|
155
|
+
resolveAuthorLabel,
|
|
156
|
+
leaderCombos,
|
|
157
|
+
onLeaderCommand,
|
|
151
158
|
onExit,
|
|
152
159
|
theme,
|
|
153
160
|
}: ThreadViewProps): React.ReactNode {
|
|
@@ -174,6 +181,9 @@ export function ThreadView({
|
|
|
174
181
|
onAnnotate,
|
|
175
182
|
onReply,
|
|
176
183
|
onUpdateAnnotation,
|
|
184
|
+
resolveAuthorLabel,
|
|
185
|
+
leaderCombos,
|
|
186
|
+
onLeaderCommand,
|
|
177
187
|
onExit,
|
|
178
188
|
});
|
|
179
189
|
const { palette, discussions } = surface;
|
|
@@ -22,6 +22,7 @@ export interface WelcomePlaygroundProps {
|
|
|
22
22
|
quickActions: QuickAction[];
|
|
23
23
|
/** Reports whether the playground composer is open, so the shell suspends its inbox keys while typing. */
|
|
24
24
|
onComposingChange?: (composing: boolean) => void;
|
|
25
|
+
suspended?: boolean;
|
|
25
26
|
theme?: Theme;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -86,6 +87,7 @@ export function WelcomePlayground(props: WelcomePlaygroundProps): React.ReactNod
|
|
|
86
87
|
observer={false}
|
|
87
88
|
commentsEnabled
|
|
88
89
|
fileView
|
|
90
|
+
suspended={props.suspended}
|
|
89
91
|
onComposingChange={props.onComposingChange}
|
|
90
92
|
onAnnotate={(span, body) =>
|
|
91
93
|
setAnnotations((current) => [
|