@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.33
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.diff.test.tsx +1 -1
- package/src/App.export.test.tsx +1 -1
- package/src/App.plan-review.test.tsx +6 -5
- package/src/App.readonly.test.tsx +5 -6
- package/src/App.rendering.test.tsx +2 -1
- package/src/App.review-panel.test.tsx +16 -12
- package/src/App.submit-confirm.test.tsx +2 -6
- package/src/App.test.tsx +4 -4
- package/src/App.toast.test.tsx +68 -0
- package/src/App.tsx +153 -49
- package/src/App.walk.test.tsx +1 -1
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +18 -13
- package/src/components/ConfirmCard.tsx +1 -0
- package/src/components/ConfirmDialog.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +18 -0
- package/src/components/DiffSheet.tsx +38 -12
- package/src/components/InboxList.tsx +1 -1
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +42 -0
- package/src/components/MenuBar.stories.tsx +33 -0
- package/src/components/MenuBar.tsx +58 -0
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +21 -70
- package/src/components/PromptDialog.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +4 -16
- package/src/components/ReviewPanel.tsx +10 -29
- package/src/components/ReviewRail.stories.tsx +39 -0
- package/src/components/ReviewRail.tsx +62 -57
- package/src/components/SettingsDialog.tsx +4 -3
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/SettingsRows.tsx +1 -1
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +29 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +598 -566
- package/src/components/primitives/Button.stories.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +4 -1
- package/src/components/primitives/Tabs.tsx +19 -38
- package/src/key-bindings.test.ts +4 -4
- package/src/key-bindings.ts +41 -2
- package/src/serve.test.ts +2 -2
- package/src/session-controller.share.test.ts +13 -0
- package/src/session-controller.ts +19 -1
- package/src/test-support.ts +87 -23
- package/src/version.ts +5 -0
- package/src/components/ComposeBar.stories.tsx +0 -18
- package/src/components/ComposeBar.tsx +0 -54
|
@@ -10,13 +10,13 @@ export const Variants: Story = {
|
|
|
10
10
|
render: () => (
|
|
11
11
|
<Toolbar>
|
|
12
12
|
<Button variant="solid" marginRight={2} onPress={() => {}}>
|
|
13
|
-
{" Save
|
|
13
|
+
{" Save "}
|
|
14
14
|
</Button>
|
|
15
15
|
<Button marginRight={2} onPress={() => {}}>
|
|
16
16
|
{" Cancel esc "}
|
|
17
17
|
</Button>
|
|
18
18
|
<Button variant="accent-text" onPress={() => {}}>
|
|
19
|
-
{"Submit review
|
|
19
|
+
{"Submit review"}
|
|
20
20
|
</Button>
|
|
21
21
|
</Toolbar>
|
|
22
22
|
),
|
|
@@ -15,11 +15,13 @@ export interface DialogProps {
|
|
|
15
15
|
title?: string;
|
|
16
16
|
width: number;
|
|
17
17
|
height: number;
|
|
18
|
+
/** Solid panel fill for content-heavy dialogs; default transparent. */
|
|
19
|
+
background?: string;
|
|
18
20
|
theme?: Theme;
|
|
19
21
|
children: React.ReactNode;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export function Dialog({ isOpen, title, width, height, theme, children }: DialogProps): React.ReactNode {
|
|
24
|
+
export function Dialog({ isOpen, title, width, height, background, theme, children }: DialogProps): React.ReactNode {
|
|
23
25
|
const tokens = useComponentTheme(theme);
|
|
24
26
|
if (!isOpen) return null;
|
|
25
27
|
return (
|
|
@@ -42,6 +44,7 @@ export function Dialog({ isOpen, title, width, height, theme, children }: Dialog
|
|
|
42
44
|
border: true,
|
|
43
45
|
borderStyle: FRAME_BORDER_STYLE,
|
|
44
46
|
borderColor: tokens.accent,
|
|
47
|
+
backgroundColor: background,
|
|
45
48
|
flexDirection: "column",
|
|
46
49
|
}}
|
|
47
50
|
title={title}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tabs / TabList / Tab
|
|
3
|
-
* follows the ARIA tabs pattern: Tabs owns the
|
|
4
|
-
* (`selectedKey` + `onSelectionChange`), TabList renders
|
|
5
|
-
* declares one item. Selection stays controlled from outside so
|
|
6
|
-
*
|
|
7
|
-
* native tab geometry.
|
|
2
|
+
* Tabs / TabList / Tab: a transparent, full-width rounded strip of clickable
|
|
3
|
+
* text tabs. The composition follows the ARIA tabs pattern: Tabs owns the
|
|
4
|
+
* controlled selection (`selectedKey` + `onSelectionChange`), TabList renders
|
|
5
|
+
* the strip, Tab declares one item. Selection stays controlled from outside so
|
|
6
|
+
* the keyboard grammar remains the key owner; each tab click sets the key.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
import React, { createContext, useContext
|
|
11
|
-
import type { MouseEvent, TabSelectRenderable } from "@opentui/core";
|
|
9
|
+
import React, { createContext, useContext } from "react";
|
|
12
10
|
import type { Theme } from "../../theme";
|
|
13
11
|
import { useComponentTheme } from "../theme-context";
|
|
12
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
14
13
|
|
|
15
14
|
interface TabsContextValue {
|
|
16
15
|
selectedKey: string;
|
|
@@ -62,36 +61,18 @@ export function TabList({ children }: TabListProps): React.ReactNode {
|
|
|
62
61
|
items.push({ id: child.props.id, label: child.props.children });
|
|
63
62
|
}
|
|
64
63
|
});
|
|
65
|
-
const tabWidth = Math.max(...items.map((item) => item.label.length)) + 2;
|
|
66
|
-
const selectedIndex = Math.max(
|
|
67
|
-
0,
|
|
68
|
-
items.findIndex((item) => item.id === tabs.selectedKey),
|
|
69
|
-
);
|
|
70
|
-
const stripRef = useRef<TabSelectRenderable | null>(null);
|
|
71
|
-
// selection is controlled: mirror the selected key into the native strip
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
stripRef.current?.setSelectedIndex(selectedIndex);
|
|
74
|
-
}, [selectedIndex, items.length]);
|
|
75
|
-
const onMouseUp = (event: MouseEvent): void => {
|
|
76
|
-
const strip = stripRef.current;
|
|
77
|
-
if (!strip) return;
|
|
78
|
-
const index = Math.floor((event.x - strip.x) / tabWidth);
|
|
79
|
-
const item = items[index];
|
|
80
|
-
if (item) tabs.onSelectionChange(item.id);
|
|
81
|
-
};
|
|
82
64
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/>
|
|
65
|
+
<box
|
|
66
|
+
style={{ width: "100%", height: 3, border: true, borderStyle: FRAME_BORDER_STYLE, borderColor: tokens.text, flexDirection: "row", paddingLeft: 1 }}
|
|
67
|
+
>
|
|
68
|
+
{items.map((item) => {
|
|
69
|
+
const selected = item.id === tabs.selectedKey;
|
|
70
|
+
return (
|
|
71
|
+
<box key={item.id} style={{ marginRight: 2 }} onMouseUp={() => tabs.onSelectionChange(item.id)}>
|
|
72
|
+
<text fg={selected ? (tabs.selectedColor ?? tokens.accent) : tokens.textDim}>{item.label}</text>
|
|
73
|
+
</box>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</box>
|
|
96
77
|
);
|
|
97
78
|
}
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -86,12 +86,12 @@ describe("mode layers own their keys", () => {
|
|
|
86
86
|
describe("getActiveKeys-generated status hints", () => {
|
|
87
87
|
test("normal mode hint matches the locked string", () => {
|
|
88
88
|
expect(bindings().statusHint("normal")).toBe(
|
|
89
|
-
"j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations ·
|
|
89
|
+
"j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · enter submit · q quit",
|
|
90
90
|
);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
test("card-selected hint keeps the locked Cut label", () => {
|
|
94
|
-
expect(bindings().statusHint("card")).toBe("card · e edit · x Cut · n/p cards · esc deselect ·
|
|
94
|
+
expect(bindings().statusHint("card")).toBe("card · e edit · x Cut · n/p cards · esc deselect · enter submit");
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
test("span hint", () => {
|
|
@@ -101,11 +101,11 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
test("compose hint", () => {
|
|
104
|
-
expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe("typing ·
|
|
104
|
+
expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe("typing · enter save · esc cancel");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("submit hint", () => {
|
|
108
|
-
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ ·
|
|
108
|
+
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · enter submit · esc cancel");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
test("walk hint", () => {
|
package/src/key-bindings.ts
CHANGED
|
@@ -26,6 +26,15 @@ export interface KeyLayerContext {
|
|
|
26
26
|
/** Hint templates keyed by the same view states the status line shows. */
|
|
27
27
|
export type HintMode = "normal" | "collaborator" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
|
|
28
28
|
|
|
29
|
+
export interface CheatsheetEntry {
|
|
30
|
+
keys: string;
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CheatsheetSection {
|
|
34
|
+
title: string;
|
|
35
|
+
entries: CheatsheetEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
type HintEntry =
|
|
30
39
|
| { text: string }
|
|
31
40
|
| { commands: string[]; label: string; labelFirst?: boolean };
|
|
@@ -97,8 +106,8 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
97
106
|
|
|
98
107
|
/** Status-line glyphs for named keys. */
|
|
99
108
|
const KEY_GLYPHS: Record<string, string> = {
|
|
100
|
-
return: "
|
|
101
|
-
enter: "
|
|
109
|
+
return: "enter",
|
|
110
|
+
enter: "enter",
|
|
102
111
|
escape: "esc",
|
|
103
112
|
left: "←",
|
|
104
113
|
right: "→",
|
|
@@ -360,4 +369,34 @@ export class KeyBindings {
|
|
|
360
369
|
}
|
|
361
370
|
return fragments.join(" · ");
|
|
362
371
|
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The full keybinding cheatsheet, grouped by mode. Each section sets the
|
|
375
|
+
* layer context so getActiveKeys resolves that mode's real bindings, then
|
|
376
|
+
* restores it - a rebound key shows its live binding here too.
|
|
377
|
+
*/
|
|
378
|
+
cheatsheet(): CheatsheetSection[] {
|
|
379
|
+
const saved = this.context;
|
|
380
|
+
const build = (context: KeyLayerContext, mode: HintMode, title: string): CheatsheetSection => {
|
|
381
|
+
this.context = context;
|
|
382
|
+
const entries: CheatsheetEntry[] = [];
|
|
383
|
+
for (const entry of HINT_TEMPLATES[mode]) {
|
|
384
|
+
if ("text" in entry || !entry.label) continue;
|
|
385
|
+
const keys = entry.commands
|
|
386
|
+
.map((command) => this.keyDisplayFor(command))
|
|
387
|
+
.filter((display): display is string => display !== null)
|
|
388
|
+
.join(" / ");
|
|
389
|
+
if (keys) entries.push({ keys, label: entry.label });
|
|
390
|
+
}
|
|
391
|
+
return { title, entries };
|
|
392
|
+
};
|
|
393
|
+
const sections = [
|
|
394
|
+
build({ overlay: "none", spanMode: false }, "normal", "Review"),
|
|
395
|
+
build({ overlay: "none", spanMode: true }, "span", "Selection"),
|
|
396
|
+
build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
|
|
397
|
+
build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
|
|
398
|
+
];
|
|
399
|
+
this.context = saved;
|
|
400
|
+
return sections.filter((section) => section.entries.length > 0);
|
|
401
|
+
}
|
|
363
402
|
}
|
package/src/serve.test.ts
CHANGED
|
@@ -85,8 +85,8 @@ describe("cueloop serve", () => {
|
|
|
85
85
|
// Assert
|
|
86
86
|
expect(bytes).toContain("cueloop");
|
|
87
87
|
expect(bytes).toContain("Rollout Plan");
|
|
88
|
-
// observer chrome
|
|
89
|
-
expect(bytes).toContain("observer
|
|
88
|
+
// observer chrome: the header badge marks the read-only session
|
|
89
|
+
expect(bytes).toContain("observer");
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
test("two observers can watch at once", async () => {
|
|
@@ -80,6 +80,19 @@ describe("share", () => {
|
|
|
80
80
|
// Assert
|
|
81
81
|
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
test("surfaces the ssh line as a toast, not an inline status", async () => {
|
|
85
|
+
// Arrange
|
|
86
|
+
const { controller } = await connectedController(sessionFixture());
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
controller.share();
|
|
90
|
+
await tick();
|
|
91
|
+
|
|
92
|
+
// Assert
|
|
93
|
+
expect(controller.getSnapshot().toast).toEqual({ body: "ssh p_abc123xy@cueloop.dev", title: "share link copied" });
|
|
94
|
+
expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
|
|
95
|
+
});
|
|
83
96
|
});
|
|
84
97
|
|
|
85
98
|
describe("pullShared", () => {
|
|
@@ -50,10 +50,16 @@ export const DEFAULT_AUTO_CLOSE = 5;
|
|
|
50
50
|
/** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
|
|
51
51
|
export const SHARE_POLL_MS = 4000;
|
|
52
52
|
|
|
53
|
+
export interface ToastState {
|
|
54
|
+
title?: string;
|
|
55
|
+
body: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
export interface ControllerSnapshot {
|
|
54
59
|
session: ReviewSession | null;
|
|
55
60
|
inbox: ReviewSession[] | null;
|
|
56
61
|
status: string;
|
|
62
|
+
toast: ToastState | null;
|
|
57
63
|
error: string | null;
|
|
58
64
|
completion: Completion;
|
|
59
65
|
/**
|
|
@@ -96,6 +102,8 @@ export interface ReviewController {
|
|
|
96
102
|
/** Loaded config parts the controller acts on: auto-close and exporters. */
|
|
97
103
|
applyConfig(config: CueloopConfig): void;
|
|
98
104
|
setStatus(message: string): void;
|
|
105
|
+
showToast(body: string, title?: string): void;
|
|
106
|
+
dismissToast(): void;
|
|
99
107
|
/** Derived projections, cached per session identity. */
|
|
100
108
|
display(): DisplayBlock[];
|
|
101
109
|
rows(): DiffRow[];
|
|
@@ -161,6 +169,7 @@ class Controller implements ReviewController {
|
|
|
161
169
|
session: null,
|
|
162
170
|
inbox: null,
|
|
163
171
|
status: "",
|
|
172
|
+
toast: null,
|
|
164
173
|
error: null,
|
|
165
174
|
completion: { phase: "idle" },
|
|
166
175
|
editOrphanCount: 0,
|
|
@@ -248,6 +257,14 @@ class Controller implements ReviewController {
|
|
|
248
257
|
this.update({ status: message });
|
|
249
258
|
}
|
|
250
259
|
|
|
260
|
+
showToast(body: string, title?: string): void {
|
|
261
|
+
this.update({ toast: { body, title } });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dismissToast(): void {
|
|
265
|
+
if (this.snapshot.toast) this.update({ toast: null });
|
|
266
|
+
}
|
|
267
|
+
|
|
251
268
|
// ── derived projections ─────────────────────
|
|
252
269
|
private ensureDerived(): void {
|
|
253
270
|
const session = this.snapshot.session;
|
|
@@ -515,7 +532,8 @@ class Controller implements ReviewController {
|
|
|
515
532
|
// Stamp the id back so a later pull knows which share to collect from.
|
|
516
533
|
const shareId = shareIdFromLine(line);
|
|
517
534
|
if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
|
|
518
|
-
this.setStatus(copied ?
|
|
535
|
+
this.setStatus(copied ? "✓ share link copied" : "share link ready");
|
|
536
|
+
this.showToast(line, copied ? "share link copied" : "share link");
|
|
519
537
|
})
|
|
520
538
|
.catch((error: unknown) => this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
521
539
|
}
|
package/src/test-support.ts
CHANGED
|
@@ -61,53 +61,117 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
|
|
|
61
61
|
await settle(setup);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Below the 60s per-test budget so a genuinely stuck wait throws its frame
|
|
65
|
+
// error (useful) before bun's bare "timed out" fires. Generous because a
|
|
66
|
+
// contended CI runner starves the render loop and daemon round-trips.
|
|
67
|
+
const WAIT_DEADLINE_MS = 45_000;
|
|
68
|
+
|
|
69
|
+
/** One macrotask turn: hands the event loop back so the in-process daemon can
|
|
70
|
+
* read its socket and apply the write before the next poll. Tight render-pass
|
|
71
|
+
* bursts starve that read on a contended CI runner - the round-trip then never
|
|
72
|
+
* lands and the wait hangs the whole budget. */
|
|
73
|
+
function yieldEventLoop(): Promise<void> {
|
|
74
|
+
return new Promise((resolve) => setTimeout(resolve, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Log a still-waiting line at most this often, so only genuinely slow (i.e.
|
|
78
|
+
* flaking) waits are noisy; fast waits stay silent. */
|
|
79
|
+
const PROGRESS_LOG_MS = 4000;
|
|
80
|
+
|
|
81
|
+
/** The last render frame's tail, so a progress line shows what is on screen. */
|
|
82
|
+
function frameTail(frame: string, rows = 6): string {
|
|
83
|
+
return frame.split("\n").slice(-rows).join("\n");
|
|
84
|
+
}
|
|
65
85
|
|
|
66
86
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* the
|
|
87
|
+
* Poll gently: one render pass, one frame check, one macrotask yield. No
|
|
88
|
+
* multi-pass bursts, so the daemon (same event loop) always gets a turn to
|
|
89
|
+
* process IO between checks. The deadline bounds the whole wait. Slow waits log
|
|
90
|
+
* progress (poll count + frame-change count) so a CI stall shows whether the
|
|
91
|
+
* render froze or the content simply never arrived.
|
|
72
92
|
*/
|
|
73
93
|
async function waitForFramePredicate(
|
|
74
94
|
setup: TestRendererSetup,
|
|
75
95
|
predicate: (frame: string) => boolean,
|
|
96
|
+
label: string,
|
|
76
97
|
): Promise<string> {
|
|
77
98
|
allowEventLoopUpdates();
|
|
78
|
-
const
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
101
|
+
let polls = 0;
|
|
102
|
+
let frameChanges = 0;
|
|
103
|
+
let lastFrame = "";
|
|
104
|
+
let lastLog = start;
|
|
79
105
|
for (;;) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
await setup.renderOnce();
|
|
107
|
+
const frame = setup.captureCharFrame();
|
|
108
|
+
polls++;
|
|
109
|
+
if (frame !== lastFrame) {
|
|
110
|
+
frameChanges++;
|
|
111
|
+
lastFrame = frame;
|
|
86
112
|
}
|
|
113
|
+
if (predicate(frame)) return frame;
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (now > deadline) {
|
|
116
|
+
const frozen = frameChanges <= 1 ? " — render never advanced (frozen)" : "";
|
|
117
|
+
throw new Error(
|
|
118
|
+
`waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
122
|
+
lastLog = now;
|
|
123
|
+
console.error(
|
|
124
|
+
`[waitFor] still waiting for ${label} — ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
await yieldEventLoop();
|
|
87
128
|
}
|
|
88
129
|
}
|
|
89
130
|
|
|
90
131
|
/** Wait until a state predicate holds (daemon round-trips included). */
|
|
91
|
-
export async function waitForState(
|
|
132
|
+
export async function waitForState(
|
|
133
|
+
setup: TestRendererSetup,
|
|
134
|
+
predicate: () => boolean,
|
|
135
|
+
label = "state",
|
|
136
|
+
): Promise<void> {
|
|
92
137
|
allowEventLoopUpdates();
|
|
93
|
-
const
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
140
|
+
let polls = 0;
|
|
141
|
+
let frameChanges = 0;
|
|
142
|
+
let lastFrame = "";
|
|
143
|
+
let lastLog = start;
|
|
94
144
|
for (;;) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
145
|
+
if (predicate()) return;
|
|
146
|
+
await setup.renderOnce();
|
|
147
|
+
const frame = setup.captureCharFrame();
|
|
148
|
+
polls++;
|
|
149
|
+
if (frame !== lastFrame) {
|
|
150
|
+
frameChanges++;
|
|
151
|
+
lastFrame = frame;
|
|
152
|
+
}
|
|
153
|
+
if (predicate()) return;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (now > deadline) {
|
|
156
|
+
const frozen = frameChanges <= 1 ? " — render never advanced (frozen)" : "";
|
|
157
|
+
throw new Error(
|
|
158
|
+
`waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
162
|
+
lastLog = now;
|
|
163
|
+
console.error(`[waitForState] still waiting for ${label} — ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`);
|
|
101
164
|
}
|
|
165
|
+
await yieldEventLoop();
|
|
102
166
|
}
|
|
103
167
|
}
|
|
104
168
|
|
|
105
169
|
/** Wait until the frame contains the needle (daemon round-trips included). */
|
|
106
170
|
export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
107
|
-
return waitForFramePredicate(setup, (frame) => frame.includes(needle));
|
|
171
|
+
return waitForFramePredicate(setup, (frame) => frame.includes(needle), `text ${JSON.stringify(needle)}`);
|
|
108
172
|
}
|
|
109
173
|
|
|
110
174
|
/** Wait until the frame no longer contains the needle. */
|
|
111
175
|
export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
112
|
-
return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
|
|
176
|
+
return waitForFramePredicate(setup, (frame) => !frame.includes(needle), `text-gone ${JSON.stringify(needle)}`);
|
|
113
177
|
}
|
package/src/version.ts
ADDED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { DARK } from "../theme";
|
|
3
|
-
import type { Story, StoryMeta } from "./story";
|
|
4
|
-
import { ComposeBar } from "./ComposeBar";
|
|
5
|
-
|
|
6
|
-
export const meta: StoryMeta = { title: "ComposeBar" };
|
|
7
|
-
|
|
8
|
-
export const CommentOnLine: Story = {
|
|
9
|
-
render: () => (
|
|
10
|
-
<ComposeBar kind="comment" quote=" private items = new Map();" text="Map needs an eviction story." onInput={() => {}} />
|
|
11
|
-
),
|
|
12
|
-
expectedColors: [DARK.accent, DARK.elevated],
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export const SuggestionOnLine: Story = {
|
|
16
|
-
render: () => <ComposeBar kind="suggestion" quote="export class Store {" text="" onInput={() => {}} />,
|
|
17
|
-
expectedColors: [DARK.green],
|
|
18
|
-
};
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bottom compose bar - the diff view composes annotation bodies here. The
|
|
3
|
-
* editor is the same multiline textarea contract as the inline composer:
|
|
4
|
-
* ⏎ saves through the grammar, shift+⏎ adds a line.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import React, { useRef } from "react";
|
|
8
|
-
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
9
|
-
import type { Theme } from "../theme";
|
|
10
|
-
import { useComponentTheme } from "./theme-context";
|
|
11
|
-
import { truncateToSingleLine } from "./truncate-text";
|
|
12
|
-
|
|
13
|
-
export interface ComposeBarProps {
|
|
14
|
-
kind: "comment" | "suggestion";
|
|
15
|
-
quote: string;
|
|
16
|
-
text: string;
|
|
17
|
-
onInput: (text: string) => void;
|
|
18
|
-
theme?: Theme;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
22
|
-
{ name: "return", action: "submit" },
|
|
23
|
-
{ name: "return", shift: true, action: "newline" },
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
export function ComposeBar({ kind, quote, text, onInput, theme }: ComposeBarProps): React.ReactNode {
|
|
27
|
-
const tokens = useComponentTheme(theme);
|
|
28
|
-
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
29
|
-
const editorRows = Math.min(4, Math.max(1, text.split("\n").length));
|
|
30
|
-
return (
|
|
31
|
-
<box style={{ height: 1 + editorRows, backgroundColor: tokens.elevated, flexDirection: "column", paddingLeft: 1 }}>
|
|
32
|
-
<text fg={kind === "suggestion" ? tokens.green : tokens.accent}>
|
|
33
|
-
{kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncateToSingleLine(quote, 60)}” · ⏎ save · esc cancel
|
|
34
|
-
</text>
|
|
35
|
-
<textarea
|
|
36
|
-
ref={editorRef}
|
|
37
|
-
focused
|
|
38
|
-
initialValue=""
|
|
39
|
-
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
40
|
-
onContentChange={() => {
|
|
41
|
-
const editor = editorRef.current;
|
|
42
|
-
if (editor) onInput(editor.plainText);
|
|
43
|
-
}}
|
|
44
|
-
style={{
|
|
45
|
-
height: editorRows,
|
|
46
|
-
backgroundColor: tokens.elevated,
|
|
47
|
-
focusedBackgroundColor: tokens.elevated,
|
|
48
|
-
textColor: tokens.text,
|
|
49
|
-
focusedTextColor: tokens.text,
|
|
50
|
-
}}
|
|
51
|
-
/>
|
|
52
|
-
</box>
|
|
53
|
-
);
|
|
54
|
-
}
|