@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.76
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.regression.test.tsx +94 -0
- package/src/App.test.tsx +11 -8
- package/src/App.tsx +476 -220
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +168 -123
- 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 +7 -1
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +10 -1
- 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/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 +7 -1
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
- 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.ts +1 -0
- package/src/diff-intraline.ts +26 -5
- 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/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.ts +4 -3
- package/src/use-annotation-surface.tsx +22 -0
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +9 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
|
@@ -22,6 +22,8 @@ export interface PromptDialogProps {
|
|
|
22
22
|
value: string;
|
|
23
23
|
placeholder?: string;
|
|
24
24
|
onInput: (text: string) => void;
|
|
25
|
+
onSave?: () => void;
|
|
26
|
+
onCancel?: () => void;
|
|
25
27
|
theme?: Theme;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -32,6 +34,8 @@ export function PromptDialog({
|
|
|
32
34
|
value,
|
|
33
35
|
placeholder,
|
|
34
36
|
onInput,
|
|
37
|
+
onSave,
|
|
38
|
+
onCancel,
|
|
35
39
|
theme,
|
|
36
40
|
}: PromptDialogProps): React.ReactNode {
|
|
37
41
|
const tokens = useComponentTheme(theme);
|
|
@@ -39,8 +43,9 @@ export function PromptDialog({
|
|
|
39
43
|
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
40
44
|
|
|
41
45
|
useEffect(() => {
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
if (!inputRef.current) return;
|
|
47
|
+
inputRef.current.focus();
|
|
48
|
+
inputRef.current.cursorOffset = value.length;
|
|
44
49
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
50
|
}, []);
|
|
46
51
|
if (!isOpen) return null;
|
|
@@ -52,6 +57,7 @@ export function PromptDialog({
|
|
|
52
57
|
width={Math.min(54, terminalWidth - 6)}
|
|
53
58
|
height={7}
|
|
54
59
|
background={tokens.elevated}
|
|
60
|
+
onDismiss={onCancel}
|
|
55
61
|
theme={theme}
|
|
56
62
|
>
|
|
57
63
|
<box
|
|
@@ -80,7 +86,14 @@ export function PromptDialog({
|
|
|
80
86
|
}}
|
|
81
87
|
/>
|
|
82
88
|
<box style={{ flexGrow: 1 }} />
|
|
83
|
-
<
|
|
89
|
+
<box style={{ flexDirection: "row" }}>
|
|
90
|
+
<box onMouseUp={onSave} style={{ marginRight: 3 }}>
|
|
91
|
+
<text fg={tokens.textDim}>enter save</text>
|
|
92
|
+
</box>
|
|
93
|
+
<box onMouseUp={onCancel}>
|
|
94
|
+
<text fg={tokens.textDim}>esc cancel</text>
|
|
95
|
+
</box>
|
|
96
|
+
</box>
|
|
84
97
|
</box>
|
|
85
98
|
</Dialog>
|
|
86
99
|
);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// A root overlay slot: OpenTUI has no z-index, so an overlay drawn inside a pane is sliced by the
|
|
2
2
|
// next pane's border. Anything pushed here renders after every pane (last in draw order), so it sits
|
|
3
3
|
// on top of the whole shell - the same trick the tooltip uses. Callers position their node with
|
|
4
|
-
// absolute screen coordinates
|
|
4
|
+
// absolute screen coordinates and hold a stable key, so several overlays (a menu, a hover preview)
|
|
5
|
+
// coexist instead of clobbering one shared slot.
|
|
5
6
|
|
|
6
7
|
import React, { createContext, useContext, useMemo, useState } from "react";
|
|
7
8
|
|
|
8
9
|
interface RootOverlayApi {
|
|
9
|
-
setOverlay: (node: React.ReactNode) => void;
|
|
10
|
-
clearOverlay: () => void;
|
|
10
|
+
setOverlay: (key: string, node: React.ReactNode) => void;
|
|
11
|
+
clearOverlay: (key: string) => void;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const RootOverlayContext = createContext<RootOverlayApi>({
|
|
@@ -20,16 +21,28 @@ export function useRootOverlay(): RootOverlayApi {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function RootOverlayProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
23
|
-
const [
|
|
24
|
+
const [overlays, setOverlays] = useState<Map<string, React.ReactNode>>(new Map());
|
|
24
25
|
const api = useMemo<RootOverlayApi>(
|
|
25
|
-
() => ({
|
|
26
|
+
() => ({
|
|
27
|
+
setOverlay: (key, node) => setOverlays((current) => new Map(current).set(key, node)),
|
|
28
|
+
clearOverlay: (key) =>
|
|
29
|
+
setOverlays((current) => {
|
|
30
|
+
if (!current.has(key)) return current;
|
|
31
|
+
const next = new Map(current);
|
|
32
|
+
next.delete(key);
|
|
33
|
+
|
|
34
|
+
return next;
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
26
37
|
[],
|
|
27
38
|
);
|
|
28
39
|
|
|
29
40
|
return (
|
|
30
41
|
<RootOverlayContext.Provider value={api}>
|
|
31
42
|
{children}
|
|
32
|
-
{
|
|
43
|
+
{[...overlays].map(([key, node]) => (
|
|
44
|
+
<React.Fragment key={key}>{node}</React.Fragment>
|
|
45
|
+
))}
|
|
33
46
|
</RootOverlayContext.Provider>
|
|
34
47
|
);
|
|
35
48
|
}
|
|
@@ -10,7 +10,6 @@ const CATEGORIES: SettingsCategory[] = [
|
|
|
10
10
|
{
|
|
11
11
|
id: "general",
|
|
12
12
|
name: "General",
|
|
13
|
-
description: "identity and submission",
|
|
14
13
|
rows: [
|
|
15
14
|
{ key: "displayName", label: "Display name", kind: "text" },
|
|
16
15
|
{
|
|
@@ -24,7 +23,6 @@ const CATEGORIES: SettingsCategory[] = [
|
|
|
24
23
|
{
|
|
25
24
|
id: "display",
|
|
26
25
|
name: "Display",
|
|
27
|
-
description: "plan width and chrome",
|
|
28
26
|
rows: [
|
|
29
27
|
{
|
|
30
28
|
key: "planWidth",
|
|
@@ -38,7 +36,6 @@ const CATEGORIES: SettingsCategory[] = [
|
|
|
38
36
|
{
|
|
39
37
|
id: "appearance",
|
|
40
38
|
name: "Appearance",
|
|
41
|
-
description: "the color theme",
|
|
42
39
|
rows: [
|
|
43
40
|
{
|
|
44
41
|
key: "theme",
|
|
@@ -71,6 +68,7 @@ export const NavZone: Story = {
|
|
|
71
68
|
activeZone="nav"
|
|
72
69
|
onCategorySelect={() => {}}
|
|
73
70
|
onRowActivate={() => {}}
|
|
71
|
+
onClose={() => {}}
|
|
74
72
|
/>
|
|
75
73
|
),
|
|
76
74
|
expectedColors: [DARK.accent],
|
|
@@ -90,6 +88,7 @@ export const BodyZone: Story = {
|
|
|
90
88
|
activeZone="body"
|
|
91
89
|
onCategorySelect={() => {}}
|
|
92
90
|
onRowActivate={() => {}}
|
|
91
|
+
onClose={() => {}}
|
|
93
92
|
/>
|
|
94
93
|
),
|
|
95
94
|
expectedColors: [DARK.green],
|
|
@@ -109,6 +108,7 @@ export const AppearanceThemed: Story = {
|
|
|
109
108
|
activeZone="body"
|
|
110
109
|
onCategorySelect={() => {}}
|
|
111
110
|
onRowActivate={() => {}}
|
|
111
|
+
onClose={() => {}}
|
|
112
112
|
theme={THEME_PRESETS["rose-pine-moon"]}
|
|
113
113
|
/>
|
|
114
114
|
),
|
|
@@ -21,7 +21,6 @@ import { CycleRow, TextRow, ToggleRow, type SettingsRowDescriptor } from "./Sett
|
|
|
21
21
|
export interface SettingsCategory {
|
|
22
22
|
id: string;
|
|
23
23
|
name: string;
|
|
24
|
-
description: string;
|
|
25
24
|
rows: SettingsRowDescriptor[];
|
|
26
25
|
/** A bespoke body (e.g. the quick-actions editor) rendered instead of typed rows. */
|
|
27
26
|
customBody?: React.ReactNode;
|
|
@@ -45,6 +44,7 @@ export interface SettingsDialogProps {
|
|
|
45
44
|
activeZone: "nav" | "body";
|
|
46
45
|
onCategorySelect: (categoryId: string) => void;
|
|
47
46
|
onRowActivate: (row: SettingsRowDescriptor) => void;
|
|
47
|
+
onClose: () => void;
|
|
48
48
|
theme?: Theme;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -88,6 +88,7 @@ export function SettingsDialog({
|
|
|
88
88
|
activeZone,
|
|
89
89
|
onCategorySelect,
|
|
90
90
|
onRowActivate,
|
|
91
|
+
onClose,
|
|
91
92
|
theme,
|
|
92
93
|
}: SettingsDialogProps): React.ReactNode {
|
|
93
94
|
const tokens = useComponentTheme(theme);
|
|
@@ -107,6 +108,7 @@ export function SettingsDialog({
|
|
|
107
108
|
width={Math.min(76, terminalWidth - 6)}
|
|
108
109
|
height={Math.min(22, terminalHeight - 4)}
|
|
109
110
|
background={tokens.elevated}
|
|
111
|
+
onDismiss={onClose}
|
|
110
112
|
theme={theme}
|
|
111
113
|
>
|
|
112
114
|
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
@@ -123,9 +125,6 @@ export function SettingsDialog({
|
|
|
123
125
|
</box>
|
|
124
126
|
<box style={{ borderStyle: "single", border: ["left"], borderColor: tokens.border }} />
|
|
125
127
|
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
|
|
126
|
-
<text fg={tokens.text}>{category.name}</text>
|
|
127
|
-
<text fg={tokens.textDim}>{category.description}</text>
|
|
128
|
-
<box style={{ height: 1 }} />
|
|
129
128
|
{onKeybinds ? <KeybindsBody sections={keybindsSections} tokens={tokens} /> : null}
|
|
130
129
|
{onKeybinds ? null : category.customBody}
|
|
131
130
|
{onKeybinds
|
|
@@ -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,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import React, { useEffect, useRef } from "react";
|
|
16
|
-
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
16
|
+
import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
|
|
17
17
|
import type { Thread } from "@cueloop/schema";
|
|
18
18
|
import { displayText, type DisplayBlock, type Mark } from "../view-plan";
|
|
19
19
|
import type { TextSpan } from "../thread-selection";
|
|
@@ -127,6 +127,8 @@ 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
|
+
leaderCombos?: readonly string[];
|
|
131
|
+
onLeaderCommand?: (key: KeyEvent) => void;
|
|
130
132
|
onExit: () => void;
|
|
131
133
|
theme?: Theme;
|
|
132
134
|
}
|
|
@@ -148,6 +150,8 @@ export function ThreadView({
|
|
|
148
150
|
onAnnotate,
|
|
149
151
|
onReply,
|
|
150
152
|
onUpdateAnnotation,
|
|
153
|
+
leaderCombos,
|
|
154
|
+
onLeaderCommand,
|
|
151
155
|
onExit,
|
|
152
156
|
theme,
|
|
153
157
|
}: ThreadViewProps): React.ReactNode {
|
|
@@ -174,6 +178,8 @@ export function ThreadView({
|
|
|
174
178
|
onAnnotate,
|
|
175
179
|
onReply,
|
|
176
180
|
onUpdateAnnotation,
|
|
181
|
+
leaderCombos,
|
|
182
|
+
onLeaderCommand,
|
|
177
183
|
onExit,
|
|
178
184
|
});
|
|
179
185
|
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) => [
|