@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- package/src/components/plan-sheet-run-style.ts +0 -90
package/src/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The cueloop TUI: state wiring, keymap dispatch, and layout composition -
|
|
3
3
|
* nothing else. Rendering lives in components/, daemon IO and the mutation
|
|
4
|
-
*
|
|
4
|
+
* primitives in session-controller.ts, the keyboard grammar in keymap.ts with
|
|
5
5
|
* binding resolution and status hints from key-bindings.ts, and theming in
|
|
6
6
|
* the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
|
|
7
7
|
* span on one native renderer selection); annotation text lives in the rail
|
|
@@ -19,7 +19,7 @@ import React, {
|
|
|
19
19
|
} from "react";
|
|
20
20
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
21
21
|
import type { Clock, MouseEvent } from "@opentui/core";
|
|
22
|
-
import {
|
|
22
|
+
import { marksByDisplay, type Mark } from "./view-plan";
|
|
23
23
|
import { dimmedTheme } from "./theme";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_KEYS,
|
|
@@ -37,21 +37,27 @@ import {
|
|
|
37
37
|
type ThemeName,
|
|
38
38
|
} from "./theme-presets";
|
|
39
39
|
import type { Theme } from "./theme";
|
|
40
|
-
import { createReviewController } from "./session-controller";
|
|
40
|
+
import { createReviewController, type ShareTransport } from "./session-controller";
|
|
41
41
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
42
42
|
import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
|
|
43
|
-
import {
|
|
44
|
-
import { reduceKey } from "./keymap";
|
|
45
|
-
import { KeyBindings } from "./key-bindings";
|
|
43
|
+
import { createIntentDispatch, type Mode } from "./intent-dispatch";
|
|
44
|
+
import { reduceKey, type KeyState } from "./keymap";
|
|
45
|
+
import { KeyBindings, type CheatsheetSection } from "./key-bindings";
|
|
46
46
|
import { ThemeProvider } from "./components/theme-context";
|
|
47
47
|
import { Button } from "./components/primitives/Button";
|
|
48
48
|
import { Toolbar } from "./components/primitives/Toolbar";
|
|
49
49
|
import { Breadcrumb } from "./components/Breadcrumb";
|
|
50
|
-
import {
|
|
50
|
+
import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
|
|
51
|
+
import {
|
|
52
|
+
RAIL_CHORD_ENTRIES,
|
|
53
|
+
resolveThreadChord,
|
|
54
|
+
THREAD_CHORD_ENTRIES,
|
|
55
|
+
TREE_CHORD_ENTRIES,
|
|
56
|
+
} from "./thread-chords";
|
|
51
57
|
import { DiffSheet } from "./components/DiffSheet";
|
|
52
58
|
import { PrototypeSheet } from "./components/PrototypeSheet";
|
|
53
59
|
import type { PrototypeElement } from "./prototype-browser";
|
|
54
|
-
import { type ReviewRailHandle } from "./components/ReviewRail";
|
|
60
|
+
import { type RailTab, type ReviewRailHandle } from "./components/ReviewRail";
|
|
55
61
|
import type { AgentTerminalHandle } from "./components/agent-launcher";
|
|
56
62
|
import { ReviewPanel } from "./components/ReviewPanel";
|
|
57
63
|
import {
|
|
@@ -62,12 +68,9 @@ import {
|
|
|
62
68
|
type ReviewPanelMode,
|
|
63
69
|
} from "./review-panel";
|
|
64
70
|
import {
|
|
65
|
-
buildActiveSpan,
|
|
66
71
|
buildCardEditState,
|
|
67
|
-
buildComposeState,
|
|
68
72
|
buildDiffComposeState,
|
|
69
73
|
buildHeaderItems,
|
|
70
|
-
buildPopoverState,
|
|
71
74
|
buildRenderFlags,
|
|
72
75
|
buildSubmitConfirmState,
|
|
73
76
|
computePendingCount,
|
|
@@ -96,7 +99,7 @@ export interface AppProps {
|
|
|
96
99
|
home?: string;
|
|
97
100
|
sessionId?: string;
|
|
98
101
|
/**
|
|
99
|
-
* Observer mode (SSH-served connections): every mutating
|
|
102
|
+
* Observer mode (SSH-served connections): every mutating primitive is ignored and
|
|
100
103
|
* answers "observer - read-only" in the status line; navigation still works.
|
|
101
104
|
*/
|
|
102
105
|
readOnly?: boolean;
|
|
@@ -105,6 +108,7 @@ export interface AppProps {
|
|
|
105
108
|
clock?: Clock;
|
|
106
109
|
/** Session source; the sharing gateway injects a blob-backed client. */
|
|
107
110
|
openClient?: () => Promise<SessionClient>;
|
|
111
|
+
shareTransport?: ShareTransport;
|
|
108
112
|
/**
|
|
109
113
|
* Who is at the keyboard. `owner` is the local planner (default). `observer`
|
|
110
114
|
* is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
|
|
@@ -124,6 +128,37 @@ export interface AppProps {
|
|
|
124
128
|
appearance?: Appearance;
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
/** True while the drop-up or one of its dialogs is open and owns the keyboard. */
|
|
132
|
+
function menuChromeOpen(menuOpen: boolean, menuDialog: "keybinds" | "settings" | null): boolean {
|
|
133
|
+
return menuOpen || menuDialog !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True while a menu, an overlay, or the agent terminal owns the keyboard instead of the thread view. */
|
|
137
|
+
function keyboardOwnedElsewhere(
|
|
138
|
+
menuOwnsKeyboard: boolean,
|
|
139
|
+
overlay: KeyState["overlay"],
|
|
140
|
+
agentTerminal: AgentTerminalHandle | null,
|
|
141
|
+
): boolean {
|
|
142
|
+
return menuOwnsKeyboard || overlay !== "none" || agentTerminal !== null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
|
|
146
|
+
function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
|
|
147
|
+
const base = keyBindings.cheatsheet();
|
|
148
|
+
|
|
149
|
+
if (!threadViewActive) {
|
|
150
|
+
return base;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
...THREAD_VIEW_CHEATSHEET,
|
|
155
|
+
{ title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
|
|
156
|
+
{ title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
|
|
157
|
+
{ title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
|
|
158
|
+
...base.filter((section) => section.title === "Agent terminal"),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
127
162
|
export function App({
|
|
128
163
|
home,
|
|
129
164
|
sessionId,
|
|
@@ -131,6 +166,7 @@ export function App({
|
|
|
131
166
|
onExit,
|
|
132
167
|
clock,
|
|
133
168
|
openClient,
|
|
169
|
+
shareTransport,
|
|
134
170
|
role = "owner",
|
|
135
171
|
selfAuthor,
|
|
136
172
|
appearance = "dark",
|
|
@@ -138,7 +174,15 @@ export function App({
|
|
|
138
174
|
const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
|
|
139
175
|
const controller = useMemo(
|
|
140
176
|
() =>
|
|
141
|
-
createReviewController({
|
|
177
|
+
createReviewController({
|
|
178
|
+
home,
|
|
179
|
+
sessionId,
|
|
180
|
+
readOnly: observer,
|
|
181
|
+
onExit,
|
|
182
|
+
clock,
|
|
183
|
+
openClient,
|
|
184
|
+
shareTransport,
|
|
185
|
+
}),
|
|
142
186
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
187
|
[home, sessionId],
|
|
144
188
|
);
|
|
@@ -163,7 +207,7 @@ export function App({
|
|
|
163
207
|
useEffect(() => {
|
|
164
208
|
if (!isOwner || !session?.shareId) return;
|
|
165
209
|
|
|
166
|
-
return controller.
|
|
210
|
+
return controller.startShareSync();
|
|
167
211
|
}, [isOwner, session?.id, session?.shareId, controller]);
|
|
168
212
|
const renderer = useRenderer();
|
|
169
213
|
const { width: terminalWidth } = useTerminalDimensions();
|
|
@@ -178,7 +222,9 @@ export function App({
|
|
|
178
222
|
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
179
223
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
180
224
|
const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
|
|
181
|
-
const [railTab, setRailTab] = useState<
|
|
225
|
+
const [railTab, setRailTab] = useState<RailTab>("review");
|
|
226
|
+
// the tree row the reviewer stands on in the rail's Tree tab
|
|
227
|
+
const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
|
|
182
228
|
// A running in-tab agent terminal (embedded harness); while set, keys route to it.
|
|
183
229
|
const [agentTerminal, setAgentTerminal] = useState<AgentTerminalHandle | null>(null);
|
|
184
230
|
// review panel layout: mode + expanded width are client view state, loaded
|
|
@@ -194,7 +240,6 @@ export function App({
|
|
|
194
240
|
// live mirror of overlay input text: refs commit synchronously, so the
|
|
195
241
|
// RETURN handler never reads a stale value mid-typing
|
|
196
242
|
const liveInput = useRef("");
|
|
197
|
-
const planSheetRef = useRef<PlanSheetHandle | null>(null);
|
|
198
243
|
const railRef = useRef<ReviewRailHandle | null>(null);
|
|
199
244
|
// keymap from layered config; the loaded theme swaps the provider value
|
|
200
245
|
const keysRef = useRef(DEFAULT_KEYS);
|
|
@@ -295,6 +340,9 @@ export function App({
|
|
|
295
340
|
return ids;
|
|
296
341
|
}, [marks]);
|
|
297
342
|
const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
|
|
343
|
+
// plans and replies open in the thread view; diffs and prototypes keep their sheets
|
|
344
|
+
const threadViewActive = session !== null && !isDiff && !isPrototype;
|
|
345
|
+
const [threadComposing, setThreadComposing] = useState(false);
|
|
298
346
|
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
299
347
|
// sort position per annotation so the rail interleaves annotation and removal
|
|
300
348
|
// cards in one line-ordered stack: a diff row carries its blockIndex; a plan
|
|
@@ -305,7 +353,8 @@ export function App({
|
|
|
305
353
|
if (!session) return positions;
|
|
306
354
|
if (isDiff) {
|
|
307
355
|
for (const annotation of session.annotations) {
|
|
308
|
-
const blockIndex =
|
|
356
|
+
const blockIndex =
|
|
357
|
+
"blockIndex" in annotation.anchor ? annotation.anchor.blockIndex : undefined;
|
|
309
358
|
|
|
310
359
|
if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
|
|
311
360
|
}
|
|
@@ -324,18 +373,6 @@ export function App({
|
|
|
324
373
|
const walking = isWalking(isDiff, walk);
|
|
325
374
|
const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
|
|
326
375
|
|
|
327
|
-
// driving needs committed layout, so it runs after render; any transition
|
|
328
|
-
// out of span mode clears the renderer selection (compose paints its own
|
|
329
|
-
// mark, and a mouse drag never changes the mode, so it survives)
|
|
330
|
-
useEffect(() => {
|
|
331
|
-
// one marker at a time: clear any prior selection, then paint the current
|
|
332
|
-
// span (span and its quick-actions sub-mode both keep it painted)
|
|
333
|
-
const span = activeSpanState(mode);
|
|
334
|
-
|
|
335
|
-
planSheetRef.current?.clearSelection();
|
|
336
|
-
if (span) planSheetRef.current?.driveSpanSelection(span);
|
|
337
|
-
}, [mode]);
|
|
338
|
-
|
|
339
376
|
// ── selection symmetry: one selected id, both sides ──
|
|
340
377
|
const pulse = (id: string): void => {
|
|
341
378
|
setPulsedAnnotationId(id);
|
|
@@ -348,7 +385,6 @@ export function App({
|
|
|
348
385
|
for (const [displayIndex, blockMarks] of marks) {
|
|
349
386
|
if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
|
|
350
387
|
setCursor(displayIndex);
|
|
351
|
-
planSheetRef.current?.revealBlock(displayIndex);
|
|
352
388
|
|
|
353
389
|
return;
|
|
354
390
|
}
|
|
@@ -374,7 +410,6 @@ export function App({
|
|
|
374
410
|
|
|
375
411
|
if (!item) return;
|
|
376
412
|
setCursor(item.revealIndex);
|
|
377
|
-
if (item.source === "plan") planSheetRef.current?.revealBlock(item.revealIndex);
|
|
378
413
|
};
|
|
379
414
|
|
|
380
415
|
// the selected removal card's undo button: same restore path as the u key,
|
|
@@ -433,6 +468,8 @@ export function App({
|
|
|
433
468
|
terminalWidth,
|
|
434
469
|
focusedAnnotationId,
|
|
435
470
|
selectedCurationId,
|
|
471
|
+
railTab,
|
|
472
|
+
selectedEntryId,
|
|
436
473
|
authorNames,
|
|
437
474
|
quickActions,
|
|
438
475
|
renameAuthor: (id: string, name: string) => {
|
|
@@ -441,13 +478,13 @@ export function App({
|
|
|
441
478
|
},
|
|
442
479
|
liveInput,
|
|
443
480
|
reviewWidthRef,
|
|
444
|
-
planSheetRef,
|
|
445
481
|
setCursor,
|
|
446
482
|
setInboxCursor,
|
|
447
483
|
setMode,
|
|
448
484
|
setReviewMode,
|
|
449
485
|
setReviewWidth,
|
|
450
486
|
setRailTab,
|
|
487
|
+
setSelectedEntryId,
|
|
451
488
|
setFocusedAnnotationId,
|
|
452
489
|
setSelectedCurationId,
|
|
453
490
|
setPulsedAnnotationId,
|
|
@@ -458,7 +495,28 @@ export function App({
|
|
|
458
495
|
|
|
459
496
|
const overlay = resolveOverlay(mode, completion.phase, walking);
|
|
460
497
|
|
|
498
|
+
const menuOwnsKeyboard = menuChromeOpen(menuOpen, menuDialog);
|
|
499
|
+
// an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
|
|
500
|
+
// from the thread view; the view suspends its own grammar meanwhile
|
|
501
|
+
const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, agentTerminal);
|
|
502
|
+
|
|
461
503
|
useKeyboard((key) => {
|
|
504
|
+
// The thread view owns the document grammar while active (its own
|
|
505
|
+
// useKeyboard handles marks, comments, and ctrl+q); the session chords
|
|
506
|
+
// (submit, share, edit, walk, the rail) resolve here, and the keymap only
|
|
507
|
+
// sees keys while an overlay or the menu owns them.
|
|
508
|
+
if (threadViewActive && !threadViewSuspended) {
|
|
509
|
+
const chord = resolveThreadChord(key, {
|
|
510
|
+
composing: threadComposing,
|
|
511
|
+
isOwner,
|
|
512
|
+
resolved,
|
|
513
|
+
treeActive: railTab === "tree",
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (chord) dispatch(chord);
|
|
517
|
+
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
462
520
|
// The prototype compose textarea owns the keyboard while open: let it receive
|
|
463
521
|
// the typed note instead of the global keymap acting on each letter.
|
|
464
522
|
if (prototypeComposing) return;
|
|
@@ -515,7 +573,7 @@ export function App({
|
|
|
515
573
|
theme={theme}
|
|
516
574
|
setMenuOpen={setMenuOpen}
|
|
517
575
|
setMenuDialog={setMenuDialog}
|
|
518
|
-
keybindsSections={keyBindings
|
|
576
|
+
keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
|
|
519
577
|
settingsCategories={settingsCategories}
|
|
520
578
|
settingsValues={settingsValues}
|
|
521
579
|
settingsNav={settingsNav}
|
|
@@ -554,7 +612,6 @@ export function App({
|
|
|
554
612
|
/>
|
|
555
613
|
);
|
|
556
614
|
|
|
557
|
-
const composeState = buildComposeState({ mode, isDiff, display, liveInput, setMode, dispatch });
|
|
558
615
|
const diffComposeState = buildDiffComposeState({
|
|
559
616
|
mode,
|
|
560
617
|
isDiff,
|
|
@@ -563,17 +620,6 @@ export function App({
|
|
|
563
620
|
setMode,
|
|
564
621
|
dispatch,
|
|
565
622
|
});
|
|
566
|
-
const activeSpan = buildActiveSpan(mode, isDiff);
|
|
567
|
-
const popoverState = buildPopoverState({
|
|
568
|
-
mode,
|
|
569
|
-
isDiff,
|
|
570
|
-
quickActions,
|
|
571
|
-
isOwner,
|
|
572
|
-
observer,
|
|
573
|
-
resolved,
|
|
574
|
-
controller,
|
|
575
|
-
dispatch,
|
|
576
|
-
});
|
|
577
623
|
const submitConfirmState = buildSubmitConfirmState({
|
|
578
624
|
mode,
|
|
579
625
|
isDiff,
|
|
@@ -600,39 +646,6 @@ export function App({
|
|
|
600
646
|
resolvedIds,
|
|
601
647
|
});
|
|
602
648
|
|
|
603
|
-
// a mouse drag leaves a native selection: turn it into a word span so the
|
|
604
|
-
// marker popover opens at the dragged range, mirroring the `v` grammar.
|
|
605
|
-
// Returns whether a native selection existed (the release ended a drag).
|
|
606
|
-
const activateSpanFromSelection = (preferredIndex?: number): boolean => {
|
|
607
|
-
if (!renderer?.hasSelection) return false;
|
|
608
|
-
// the release block re-anchors the span, so each drag replaces the last
|
|
609
|
-
const selection = planSheetRef.current?.readSelection(preferredIndex);
|
|
610
|
-
const block = selection ? display[selection.displayIndex] : undefined;
|
|
611
|
-
const span =
|
|
612
|
-
selection && block
|
|
613
|
-
? spanFromRange(selection.displayIndex, displayText(block), selection.start, selection.end)
|
|
614
|
-
: null;
|
|
615
|
-
|
|
616
|
-
if (span) setMode({ type: "span", span });
|
|
617
|
-
|
|
618
|
-
return true;
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
const onLineActivate = (displayIndex: number): void => {
|
|
622
|
-
if (activateSpanFromSelection(displayIndex)) return;
|
|
623
|
-
setCursor(displayIndex);
|
|
624
|
-
const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
|
|
625
|
-
|
|
626
|
-
if (annotationId) selectCardFromDocument(annotationId);
|
|
627
|
-
};
|
|
628
|
-
|
|
629
|
-
// a drag released outside any block's text (the gutter, past a line end, a
|
|
630
|
-
// gap between blocks) never reaches a block handler; the sheet-level release
|
|
631
|
-
// still turns the finished drag into a span
|
|
632
|
-
const onSelectionRelease = (): void => {
|
|
633
|
-
activateSpanFromSelection();
|
|
634
|
-
};
|
|
635
|
-
|
|
636
649
|
const onEditRequest = (): void => {
|
|
637
650
|
// A share viewer/observer has no Edit affordance (the button is hidden), so
|
|
638
651
|
// this is owner-only; stay silent rather than nag if it is ever reached.
|
|
@@ -734,18 +747,37 @@ export function App({
|
|
|
734
747
|
theme={walking ? dimmedTheme(theme) : undefined}
|
|
735
748
|
/>
|
|
736
749
|
) : (
|
|
737
|
-
<
|
|
738
|
-
ref={planSheetRef}
|
|
750
|
+
<ThreadView
|
|
739
751
|
session={activeSession}
|
|
752
|
+
suspended={threadViewSuspended}
|
|
753
|
+
editOrphanCount={editOrphanCount}
|
|
754
|
+
onComposingChange={setThreadComposing}
|
|
755
|
+
resolved={resolved}
|
|
756
|
+
onObserverBlocked={(reason) =>
|
|
757
|
+
controller.setStatus(
|
|
758
|
+
reason === "observer" ? "observer - read-only" : "review submitted - read-only",
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
onCursorChange={setCursor}
|
|
762
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
763
|
+
onFocusAnnotation={setFocusedAnnotationId}
|
|
740
764
|
display={display}
|
|
741
765
|
marks={marks}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
766
|
+
quickActions={quickActions}
|
|
767
|
+
observer={observer}
|
|
768
|
+
onAnnotate={(span, body) =>
|
|
769
|
+
void controller.annotate(
|
|
770
|
+
"comment",
|
|
771
|
+
span.start.blockIndex,
|
|
772
|
+
span.start.char,
|
|
773
|
+
span.end.char,
|
|
774
|
+
body,
|
|
775
|
+
span.end.blockIndex,
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
onReply={(rootAnnotationId, body) => void controller.reply(rootAnnotationId, body)}
|
|
779
|
+
onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
|
|
780
|
+
onExit={() => onExit?.(0)}
|
|
749
781
|
/>
|
|
750
782
|
)}
|
|
751
783
|
<ReviewPanel
|
|
@@ -779,6 +811,21 @@ export function App({
|
|
|
779
811
|
seedText,
|
|
780
812
|
}),
|
|
781
813
|
onAgentTerminal: setAgentTerminal,
|
|
814
|
+
tree: {
|
|
815
|
+
rows: controller.treeRows(),
|
|
816
|
+
selectedEntryId,
|
|
817
|
+
canMove: isOwner && !resolved,
|
|
818
|
+
canFork: isOwner,
|
|
819
|
+
onSelect: setSelectedEntryId,
|
|
820
|
+
onGo: (entryId) => {
|
|
821
|
+
setSelectedEntryId(entryId);
|
|
822
|
+
dispatch({ type: "treeGo" });
|
|
823
|
+
},
|
|
824
|
+
onBranch: () => dispatch({ type: "treeBranch" }),
|
|
825
|
+
onLabel: () => dispatch({ type: "treeLabel" }),
|
|
826
|
+
onFork: () => dispatch({ type: "treeFork" }),
|
|
827
|
+
onForkAndShare: () => dispatch({ type: "treeForkShare" }),
|
|
828
|
+
},
|
|
782
829
|
}}
|
|
783
830
|
/>
|
|
784
831
|
</box>
|
package/src/app-screens.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React, { type Dispatch, type SetStateAction } from "react";
|
|
|
2
2
|
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
3
3
|
import { returnPaneFor } from "@cueloop/schema";
|
|
4
4
|
import type { Theme } from "./theme";
|
|
5
|
-
import type { Mode } from "./intent-dispatch";
|
|
5
|
+
import type { Mode, TreeAsk } from "./intent-dispatch";
|
|
6
6
|
import type { Intent } from "./keymap";
|
|
7
7
|
import type { ReviewController, ToastState } from "./session-controller";
|
|
8
8
|
import { noteForFile } from "./walk";
|
|
@@ -195,6 +195,21 @@ export function CompletionScreen(props: {
|
|
|
195
195
|
);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** The words each tree prompt uses; enter with an empty summary moves back without one. */
|
|
199
|
+
const TREE_PROMPTS: Record<TreeAsk, { title: string; label: string; placeholder: string }> = {
|
|
200
|
+
branch: { title: " Branch ", label: "Name for the new branch:", placeholder: "alt" },
|
|
201
|
+
label: {
|
|
202
|
+
title: " Checkpoint ",
|
|
203
|
+
label: "Name for this checkpoint:",
|
|
204
|
+
placeholder: "before the rewrite",
|
|
205
|
+
},
|
|
206
|
+
navigate: {
|
|
207
|
+
title: " Move back ",
|
|
208
|
+
label: "Summary of what you leave behind (optional):",
|
|
209
|
+
placeholder: "tried a shorter plan",
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
198
213
|
export function TrailingOverlays(props: {
|
|
199
214
|
walking: boolean;
|
|
200
215
|
walk: { index: number } | null;
|
|
@@ -264,6 +279,17 @@ export function TrailingOverlays(props: {
|
|
|
264
279
|
theme={theme}
|
|
265
280
|
/>
|
|
266
281
|
) : null}
|
|
282
|
+
{mode.type === "treePrompt" ? (
|
|
283
|
+
<PromptDialog
|
|
284
|
+
isOpen
|
|
285
|
+
title={TREE_PROMPTS[mode.ask].title}
|
|
286
|
+
label={TREE_PROMPTS[mode.ask].label}
|
|
287
|
+
value={mode.text}
|
|
288
|
+
placeholder={TREE_PROMPTS[mode.ask].placeholder}
|
|
289
|
+
onInput={(text) => setMode({ ...mode, text })}
|
|
290
|
+
theme={theme}
|
|
291
|
+
/>
|
|
292
|
+
) : null}
|
|
267
293
|
{toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
|
|
268
294
|
</>
|
|
269
295
|
);
|
package/src/app-view-model.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
2
2
|
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
3
|
-
import {
|
|
4
|
-
import { displayText, type DisplayBlock } from "./view-plan";
|
|
3
|
+
import { reviewerAnnotations, type Mode } from "./intent-dispatch";
|
|
5
4
|
import type { DiffRow } from "./view-diff";
|
|
6
5
|
import type { Intent, KeyState } from "./keymap";
|
|
7
|
-
import type { Completion
|
|
8
|
-
import type { QuickAction } from "./config";
|
|
6
|
+
import type { Completion } from "./session-controller";
|
|
9
7
|
import type { WalkFile } from "./walk";
|
|
10
8
|
import { viewedCount } from "./walk";
|
|
11
|
-
import type { PlanComposeState, PlanPopoverState } from "./components/PlanSheet";
|
|
12
9
|
import type { DiffComposeState } from "./components/DiffSheet";
|
|
13
10
|
import type { ConfirmCardProps } from "./components/ConfirmCard";
|
|
14
11
|
import type { RailCardEdit } from "./components/ReviewRail";
|
|
@@ -18,17 +15,13 @@ import { REVIEW_COMPACT_WIDTH, resolveReviewWidth, type ReviewPanelMode } from "
|
|
|
18
15
|
export function computeRoleCapabilities(
|
|
19
16
|
readOnly: boolean,
|
|
20
17
|
role: "owner" | "observer" | "collaborator",
|
|
21
|
-
)
|
|
18
|
+
) {
|
|
22
19
|
const observer = readOnly || role === "observer";
|
|
23
20
|
|
|
24
21
|
return { observer, isOwner: !observer && role === "owner" };
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
export function deriveReviewFlags(session: ReviewSession | null)
|
|
28
|
-
isDiff: boolean;
|
|
29
|
-
isPrototype: boolean;
|
|
30
|
-
resolved: boolean;
|
|
31
|
-
} {
|
|
24
|
+
export function deriveReviewFlags(session: ReviewSession | null) {
|
|
32
25
|
return {
|
|
33
26
|
isDiff: session?.artifact.type === "diff",
|
|
34
27
|
isPrototype: session?.artifact.type === "prototype",
|
|
@@ -48,7 +41,8 @@ export function resolveOverlay(
|
|
|
48
41
|
if (mode.type === "compose" || mode.type === "railEdit") return "compose";
|
|
49
42
|
if (mode.type === "submit") return "submit";
|
|
50
43
|
if (mode.type === "confirmDelete") return "confirm";
|
|
51
|
-
if (mode.type === "rename" || mode.type === "nameSelf"
|
|
44
|
+
if (mode.type === "rename" || mode.type === "nameSelf" || mode.type === "treePrompt")
|
|
45
|
+
return "prompt";
|
|
52
46
|
if (mode.type === "spanActions") return "spanActions";
|
|
53
47
|
if (completionPhase === "prompt") return "completion-prompt";
|
|
54
48
|
if (completionPhase === "counting") return "completion-counting";
|
|
@@ -121,13 +115,7 @@ export function buildRenderFlags(params: {
|
|
|
121
115
|
menuOpen: boolean;
|
|
122
116
|
menuDialog: "keybinds" | "settings" | null;
|
|
123
117
|
resolvedIds: Set<string>;
|
|
124
|
-
})
|
|
125
|
-
showOwnerActions: boolean;
|
|
126
|
-
prototypeCanComment: boolean;
|
|
127
|
-
chromeHidden: boolean;
|
|
128
|
-
prototypePath: string;
|
|
129
|
-
railResolvedIds: Set<string> | null;
|
|
130
|
-
} {
|
|
118
|
+
}) {
|
|
131
119
|
const { session, isOwner, isDiff, isPrototype, resolved, menuOpen, menuDialog, resolvedIds } =
|
|
132
120
|
params;
|
|
133
121
|
|
|
@@ -140,49 +128,12 @@ export function buildRenderFlags(params: {
|
|
|
140
128
|
};
|
|
141
129
|
}
|
|
142
130
|
|
|
143
|
-
export function buildActiveSpan(
|
|
144
|
-
mode: Mode,
|
|
145
|
-
isDiff: boolean,
|
|
146
|
-
): { displayIndex: number; start: number; end: number } | null {
|
|
147
|
-
const markedSpan = activeSpanState(mode);
|
|
148
|
-
|
|
149
|
-
if (markedSpan)
|
|
150
|
-
return { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end };
|
|
151
|
-
if (mode.type === "compose" && !isDiff)
|
|
152
|
-
return { displayIndex: mode.displayIndex, start: mode.start, end: mode.end };
|
|
153
|
-
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
131
|
interface DraftHandlerDeps {
|
|
158
132
|
liveInput: MutableRefObject<string>;
|
|
159
133
|
setMode: Dispatch<SetStateAction<Mode>>;
|
|
160
134
|
dispatch: (intent: Intent) => void;
|
|
161
135
|
}
|
|
162
136
|
|
|
163
|
-
export function buildComposeState(
|
|
164
|
-
deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; display: DisplayBlock[] },
|
|
165
|
-
): PlanComposeState | null {
|
|
166
|
-
const { mode, isDiff, display, liveInput, setMode, dispatch } = deps;
|
|
167
|
-
|
|
168
|
-
if (mode.type !== "compose" || isDiff) return null;
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
kind: mode.kind,
|
|
172
|
-
displayIndex: mode.displayIndex,
|
|
173
|
-
quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
|
|
174
|
-
draft: {
|
|
175
|
-
text: mode.text,
|
|
176
|
-
onInput: (text: string) => {
|
|
177
|
-
liveInput.current = text;
|
|
178
|
-
setMode({ ...mode, text });
|
|
179
|
-
},
|
|
180
|
-
onSave: () => dispatch({ type: "saveCompose" }),
|
|
181
|
-
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
182
|
-
},
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
137
|
export function buildDiffComposeState(
|
|
187
138
|
deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; rows: DiffRow[] },
|
|
188
139
|
): DiffComposeState | null {
|
|
@@ -206,59 +157,6 @@ export function buildDiffComposeState(
|
|
|
206
157
|
};
|
|
207
158
|
}
|
|
208
159
|
|
|
209
|
-
export function buildPopoverState(deps: {
|
|
210
|
-
mode: Mode;
|
|
211
|
-
isDiff: boolean;
|
|
212
|
-
quickActions: QuickAction[];
|
|
213
|
-
isOwner: boolean;
|
|
214
|
-
observer: boolean;
|
|
215
|
-
resolved: boolean;
|
|
216
|
-
controller: ReviewController;
|
|
217
|
-
dispatch: (intent: Intent) => void;
|
|
218
|
-
}): PlanPopoverState | null {
|
|
219
|
-
const { mode, isDiff, quickActions, isOwner, observer, resolved, controller, dispatch } = deps;
|
|
220
|
-
const markedSpan = activeSpanState(mode);
|
|
221
|
-
|
|
222
|
-
if (!markedSpan || isDiff) return null;
|
|
223
|
-
const spanMutationBlock = (): string | null =>
|
|
224
|
-
observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
displayIndex: markedSpan.displayIndex,
|
|
228
|
-
view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
|
|
229
|
-
actions: quickActions,
|
|
230
|
-
actionIndex: mode.type === "spanActions" ? mode.index : 0,
|
|
231
|
-
canCut: isOwner,
|
|
232
|
-
onComment: () => {
|
|
233
|
-
const blocked = spanMutationBlock();
|
|
234
|
-
|
|
235
|
-
if (blocked) return controller.setStatus(blocked);
|
|
236
|
-
dispatch({ type: "openCompose", kind: "comment", from: "span" });
|
|
237
|
-
},
|
|
238
|
-
onCut: () => {
|
|
239
|
-
const blocked = spanMutationBlock();
|
|
240
|
-
|
|
241
|
-
if (blocked) return controller.setStatus(blocked);
|
|
242
|
-
if (!isOwner) return;
|
|
243
|
-
dispatch({ type: "spanCut" });
|
|
244
|
-
},
|
|
245
|
-
onOpenActions: () => {
|
|
246
|
-
const blocked = spanMutationBlock();
|
|
247
|
-
|
|
248
|
-
if (blocked) return controller.setStatus(blocked);
|
|
249
|
-
dispatch({ type: "openSpanActions" });
|
|
250
|
-
},
|
|
251
|
-
onClose: () => dispatch({ type: "closeOverlay" }),
|
|
252
|
-
onPickAction: (index: number) => {
|
|
253
|
-
const blocked = spanMutationBlock();
|
|
254
|
-
|
|
255
|
-
if (blocked) return controller.setStatus(blocked);
|
|
256
|
-
dispatch({ type: "pickSpanAction", index });
|
|
257
|
-
},
|
|
258
|
-
onBack: () => dispatch({ type: "closeSpanActions" }),
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
160
|
export function buildSubmitConfirmState(
|
|
263
161
|
deps: DraftHandlerDeps & {
|
|
264
162
|
mode: Mode;
|
|
@@ -181,11 +181,11 @@ export function AnnotationCard({
|
|
|
181
181
|
);
|
|
182
182
|
|
|
183
183
|
if (draft) {
|
|
184
|
-
const
|
|
184
|
+
const primitive = "comment on";
|
|
185
185
|
|
|
186
186
|
return (
|
|
187
187
|
<Card
|
|
188
|
-
title={` ${
|
|
188
|
+
title={` ${primitive} "${truncateToSingleLine(quote, 40)}" `}
|
|
189
189
|
contentRows={editorRowCount + 1}
|
|
190
190
|
borderColor={kindColor}
|
|
191
191
|
backgroundColor="transparent"
|
|
@@ -4,7 +4,7 @@ import { expect, test } from "bun:test";
|
|
|
4
4
|
import React, { useState } from "react";
|
|
5
5
|
import { testRender } from "@opentui/react/test-utils";
|
|
6
6
|
import { useKeyboard } from "@opentui/react";
|
|
7
|
-
import
|
|
7
|
+
import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
|
|
8
8
|
import type { Annotation } from "@cueloop/schema";
|
|
9
9
|
import { DiffSheet } from "./DiffSheet";
|
|
10
10
|
import { diffRows, type DiffRow } from "../view-diff";
|
|
@@ -82,7 +82,10 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
await setup.waitForVisualIdle();
|
|
85
|
-
const
|
|
85
|
+
const found = findById(setup.renderer.root, "diff-scroll");
|
|
86
|
+
|
|
87
|
+
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
88
|
+
const scrollbox = found;
|
|
86
89
|
|
|
87
90
|
// the cursor row is the one painted with the cursor background
|
|
88
91
|
const cursorScreenRow = (): number => {
|
|
@@ -60,12 +60,7 @@ export interface DiffSheetProps {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** The sign, base color, and background a row draws with. */
|
|
63
|
-
function rowStyle(
|
|
64
|
-
row: DiffRow,
|
|
65
|
-
isCursorRow: boolean,
|
|
66
|
-
isAnnotatedRow: boolean,
|
|
67
|
-
tokens: Theme,
|
|
68
|
-
): { sign: string; baseColor: string; background: string | undefined } {
|
|
63
|
+
function rowStyle(row: DiffRow, isCursorRow: boolean, isAnnotatedRow: boolean, tokens: Theme) {
|
|
69
64
|
const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
70
65
|
const baseColor =
|
|
71
66
|
row.kind === "add"
|