@cueloop/client 0.1.0-alpha.67 → 0.1.0-alpha.69
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.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.test.ts +12 -0
- package/src/test-support.ts +69 -10
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- package/src/components/WelcomeSurface.tsx +0 -52
package/src/App.tsx
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The cueloop TUI: state wiring, keymap dispatch, and layout composition -
|
|
3
|
-
* nothing else. Rendering lives in components/, daemon IO and the mutation
|
|
4
|
-
* primitives in session-controller.ts, the keyboard grammar in keymap.ts with
|
|
5
|
-
* binding resolution and status hints from key-bindings.ts, and theming in
|
|
6
|
-
* the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
|
|
7
|
-
* span on one native renderer selection); annotation text lives in the rail
|
|
8
|
-
* while the document keeps only the highlight, and one selected id drives
|
|
9
|
-
* both sides.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
1
|
import React, {
|
|
13
2
|
useCallback,
|
|
14
3
|
useEffect,
|
|
@@ -45,14 +34,15 @@ import type { SessionClient } from "@cueloop/daemon/client";
|
|
|
45
34
|
import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
|
|
46
35
|
import { reduceKey, type KeyState } from "./keymap";
|
|
47
36
|
import { KeyBindings, type CheatsheetSection } from "./key-bindings";
|
|
37
|
+
import { useReadySignal } from "./ready-signal";
|
|
48
38
|
import { ThemeProvider } from "./components/theme-context";
|
|
49
39
|
import { Button } from "./components/primitives/Button";
|
|
50
40
|
import { Toolbar } from "./components/primitives/Toolbar";
|
|
51
41
|
import { groupInbox, projectName, threadTitle } from "./components/session-tree";
|
|
52
|
-
import {
|
|
42
|
+
import { ThreadTree } from "./components/ThreadTree";
|
|
53
43
|
import { ChangesFileTree } from "./components/ChangesColumn";
|
|
54
44
|
import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
55
|
-
import {
|
|
45
|
+
import { AnnotatableFileView } from "./components/AnnotatableFileView";
|
|
56
46
|
import { AppShell, type ProjectPanelMode } from "./components/AppShell";
|
|
57
47
|
import { EditorGrid } from "./components/EditorGrid";
|
|
58
48
|
import type { EditorTab } from "./components/editor-grid";
|
|
@@ -67,16 +57,22 @@ import {
|
|
|
67
57
|
THREAD_CHORD_ENTRIES,
|
|
68
58
|
TREE_CHORD_ENTRIES,
|
|
69
59
|
} from "./thread-chords";
|
|
70
|
-
import {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
60
|
+
import {
|
|
61
|
+
DiffContentView,
|
|
62
|
+
type DiffFoldControls,
|
|
63
|
+
type DiffContentViewProps,
|
|
64
|
+
} from "./components/DiffContentView";
|
|
65
|
+
import { commentCountsByFile, fileTargetMarks, marksByRows, type DiffRow } from "./view-diff";
|
|
66
|
+
import { annotationTarget, type Anchor } from "@cueloop/schema";
|
|
67
|
+
import type { DiffFileContents, ReviewSession } from "@cueloop/schema";
|
|
68
|
+
import { PrototypePixels } from "./prototype-pixels";
|
|
74
69
|
import type { PrototypeElement } from "./prototype-browser";
|
|
75
70
|
import {
|
|
76
71
|
buildRenderFlags,
|
|
77
72
|
buildSubmitConfirmState,
|
|
78
73
|
computeRoleCapabilities,
|
|
79
74
|
deriveReviewFlags,
|
|
75
|
+
isPixelPrototypeMode,
|
|
80
76
|
isCompletionOverlayPhase,
|
|
81
77
|
isWalking,
|
|
82
78
|
resolveOverlay,
|
|
@@ -105,6 +101,8 @@ export interface AppProps {
|
|
|
105
101
|
*/
|
|
106
102
|
readOnly?: boolean;
|
|
107
103
|
onExit?: (code: number) => void;
|
|
104
|
+
/** Fired once, after the first frame that paints a usable screen with its keyboard handlers live (ready-signal.ts). */
|
|
105
|
+
onReady?: () => void;
|
|
108
106
|
/** Timer source for the auto-close countdown; tests inject a ManualClock. */
|
|
109
107
|
clock?: Clock;
|
|
110
108
|
/** Session source; the sharing gateway injects a blob-backed client. */
|
|
@@ -146,10 +144,11 @@ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean)
|
|
|
146
144
|
|
|
147
145
|
/** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
|
|
148
146
|
type DiffSurfaceProps = Pick<
|
|
149
|
-
|
|
147
|
+
DiffContentViewProps,
|
|
150
148
|
| "session"
|
|
151
149
|
| "quickActions"
|
|
152
150
|
| "observer"
|
|
151
|
+
| "commentsEnabled"
|
|
153
152
|
| "resolved"
|
|
154
153
|
| "suspended"
|
|
155
154
|
| "onComposingChange"
|
|
@@ -174,10 +173,20 @@ function ChangesTabBody(props: {
|
|
|
174
173
|
dimmed: boolean;
|
|
175
174
|
theme: Theme;
|
|
176
175
|
}): React.ReactNode {
|
|
176
|
+
const annotations = props.surface.session.annotations;
|
|
177
|
+
// a diff thread's Changes view is the artifact itself; any other thread's is the working-tree diff,
|
|
178
|
+
// whose notes carry a file target and resolve per file - each surface paints only its own notes
|
|
179
|
+
const forArtifact = props.surface.session.artifact.type === "diff";
|
|
177
180
|
const marks = useMemo(
|
|
178
181
|
() =>
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
forArtifact
|
|
183
|
+
? marksByRows(
|
|
184
|
+
annotations.filter((annotation) => annotationTarget(annotation).kind === "artifact"),
|
|
185
|
+
props.rows,
|
|
186
|
+
props.surface.focusedAnnotationId,
|
|
187
|
+
)
|
|
188
|
+
: fileTargetMarks(annotations, props.rows, props.surface.focusedAnnotationId),
|
|
189
|
+
[annotations, forArtifact, props.rows, props.surface.focusedAnnotationId],
|
|
181
190
|
);
|
|
182
191
|
|
|
183
192
|
if (props.rows.length === 0) {
|
|
@@ -189,7 +198,7 @@ function ChangesTabBody(props: {
|
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
return (
|
|
192
|
-
<
|
|
201
|
+
<DiffContentView
|
|
193
202
|
rows={props.rows}
|
|
194
203
|
marks={marks}
|
|
195
204
|
{...props.surface}
|
|
@@ -213,11 +222,35 @@ function GridTabContent(props: {
|
|
|
213
222
|
split?: boolean;
|
|
214
223
|
dimmed: boolean;
|
|
215
224
|
readFile: (path: string) => Promise<string | null>;
|
|
225
|
+
/** Persist a comment on a project file; the file view built the anchor against its own lines. */
|
|
226
|
+
onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
|
|
216
227
|
theme: Theme;
|
|
217
228
|
}): React.ReactNode {
|
|
218
229
|
const { tab } = props;
|
|
219
230
|
if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
|
|
220
|
-
|
|
231
|
+
const filePath = tab.path;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<AnnotatableFileView
|
|
235
|
+
path={filePath}
|
|
236
|
+
loadContents={props.readFile}
|
|
237
|
+
session={props.surface.session}
|
|
238
|
+
quickActions={props.surface.quickActions}
|
|
239
|
+
observer={props.surface.observer}
|
|
240
|
+
resolved={props.surface.resolved}
|
|
241
|
+
suspended={props.surface.suspended}
|
|
242
|
+
focusedAnnotationId={props.surface.focusedAnnotationId}
|
|
243
|
+
onFocusAnnotation={props.surface.onFocusAnnotation}
|
|
244
|
+
onComposingChange={props.surface.onComposingChange}
|
|
245
|
+
onCursorChange={props.surface.onCursorChange}
|
|
246
|
+
onObserverBlocked={props.surface.onObserverBlocked}
|
|
247
|
+
onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
|
|
248
|
+
onReply={props.surface.onReply}
|
|
249
|
+
onUpdateAnnotation={props.surface.onUpdateAnnotation}
|
|
250
|
+
onExit={props.surface.onExit}
|
|
251
|
+
theme={props.theme}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
221
254
|
}
|
|
222
255
|
if (tab.kind !== "file") {
|
|
223
256
|
return (
|
|
@@ -319,6 +352,15 @@ function ProjectPanelBody(props: {
|
|
|
319
352
|
);
|
|
320
353
|
}
|
|
321
354
|
|
|
355
|
+
/** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
|
|
356
|
+
function usableScreenReached(
|
|
357
|
+
error: string | null,
|
|
358
|
+
session: ReviewSession | null,
|
|
359
|
+
inbox: ReviewSession[] | null,
|
|
360
|
+
): boolean {
|
|
361
|
+
return Boolean(error) || session !== null || inbox !== null;
|
|
362
|
+
}
|
|
363
|
+
|
|
322
364
|
/** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
|
|
323
365
|
function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
|
|
324
366
|
const base = keyBindings.cheatsheet();
|
|
@@ -343,6 +385,7 @@ export function App({
|
|
|
343
385
|
cwd,
|
|
344
386
|
readOnly = false,
|
|
345
387
|
onExit,
|
|
388
|
+
onReady,
|
|
346
389
|
clock,
|
|
347
390
|
openClient,
|
|
348
391
|
shareTransport,
|
|
@@ -372,8 +415,8 @@ export function App({
|
|
|
372
415
|
|
|
373
416
|
return () => controller.close();
|
|
374
417
|
}, [controller]);
|
|
375
|
-
// stable across renders so the memoized
|
|
376
|
-
// unrelated App state (status ticks, a rail-width drag)
|
|
418
|
+
// stable across renders so the memoized PrototypeContentView is not
|
|
419
|
+
// re-rendered by unrelated App state (status ticks, a rail-width drag)
|
|
377
420
|
const onCommentPrototype = useCallback(
|
|
378
421
|
(element: PrototypeElement, body: string) =>
|
|
379
422
|
controller.annotatePrototype(element.selector, element.quote, body),
|
|
@@ -424,7 +467,7 @@ export function App({
|
|
|
424
467
|
const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
|
|
425
468
|
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
426
469
|
// unified or side-by-side diff; split only lays out when the Changes pane is zoomed
|
|
427
|
-
const [diffView, setDiffView] = useState<DiffViewMode>("
|
|
470
|
+
const [diffView, setDiffView] = useState<DiffViewMode>("split");
|
|
428
471
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
429
472
|
const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
|
|
430
473
|
const [railTab, setRailTab] = useState<RailTab>("review");
|
|
@@ -444,10 +487,13 @@ export function App({
|
|
|
444
487
|
const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
|
|
445
488
|
const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
|
|
446
489
|
const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
|
|
490
|
+
// opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
|
|
491
|
+
const [prototypePixels, setPrototypePixels] = useState(false);
|
|
447
492
|
|
|
448
493
|
useEffect(() => {
|
|
449
494
|
const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
450
495
|
|
|
496
|
+
setPrototypePixels(config.experimental.prototypePixels);
|
|
451
497
|
keysRef.current = config.keys;
|
|
452
498
|
keyBindings.setKeys(config.keys);
|
|
453
499
|
setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
|
|
@@ -517,7 +563,14 @@ export function App({
|
|
|
517
563
|
const marks = useMemo(
|
|
518
564
|
() =>
|
|
519
565
|
session
|
|
520
|
-
? marksByDisplay(
|
|
566
|
+
? marksByDisplay(
|
|
567
|
+
// the plan thread shows only artifact notes; Changes-diff notes render on that surface
|
|
568
|
+
session.annotations.filter(
|
|
569
|
+
(annotation) => annotationTarget(annotation).kind === "artifact",
|
|
570
|
+
),
|
|
571
|
+
display,
|
|
572
|
+
pulsedAnnotationId ?? undefined,
|
|
573
|
+
)
|
|
521
574
|
: new Map<number, Mark[]>(),
|
|
522
575
|
[session, display, pulsedAnnotationId],
|
|
523
576
|
);
|
|
@@ -532,6 +585,13 @@ export function App({
|
|
|
532
585
|
return ids;
|
|
533
586
|
}, [marks]);
|
|
534
587
|
const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
|
|
588
|
+
// a prototype is a markdown design doc by default; only the opt-in experimental
|
|
589
|
+
// pixel mode (flag on + an HTML entry) renders it as a kitty mockup instead
|
|
590
|
+
const isPixelPrototype = isPixelPrototypeMode(
|
|
591
|
+
isPrototype,
|
|
592
|
+
prototypePixels,
|
|
593
|
+
session?.artifact.meta.prototypePath,
|
|
594
|
+
);
|
|
535
595
|
// comments per changed-file path, for the changed-files tree and tab badges
|
|
536
596
|
const diffCommentCounts = useMemo(
|
|
537
597
|
() => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
|
|
@@ -539,11 +599,12 @@ export function App({
|
|
|
539
599
|
);
|
|
540
600
|
// entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
|
|
541
601
|
workbench.syncSession(session?.id, isDiff);
|
|
542
|
-
// plans and
|
|
543
|
-
//
|
|
544
|
-
const threadViewActive = session !== null && !
|
|
602
|
+
// plans, replies, and the default (markdown) prototype open in the thread view, diffs in the
|
|
603
|
+
// diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
|
|
604
|
+
const threadViewActive = session !== null && !isPixelPrototype;
|
|
545
605
|
const [threadComposing, setThreadComposing] = useState(false);
|
|
546
606
|
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
607
|
+
const [welcomeComposing, setWelcomeComposing] = useState(false);
|
|
547
608
|
// sort position per annotation so the rail interleaves annotation and removal
|
|
548
609
|
// cards in one line-ordered stack: a diff row carries its blockIndex; a plan
|
|
549
610
|
// annotation resolves to the display index it marked
|
|
@@ -624,12 +685,14 @@ export function App({
|
|
|
624
685
|
runEditorHandOff,
|
|
625
686
|
openCardEdit,
|
|
626
687
|
toggleDiffView: () => {
|
|
627
|
-
const next: DiffViewMode = diffView === "
|
|
688
|
+
const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
|
|
628
689
|
|
|
629
690
|
setDiffView(next);
|
|
630
691
|
persistDiffView(next);
|
|
631
|
-
|
|
632
|
-
|
|
692
|
+
// every toggle names the new mode; picking split on a narrow pane also says it needs the wide layout
|
|
693
|
+
if (next === "stacked") controller.setStatus("stacked diff");
|
|
694
|
+
else if (workbench.zoomed) controller.setStatus("split diff");
|
|
695
|
+
else controller.setStatus("split diff shows when zoomed");
|
|
633
696
|
},
|
|
634
697
|
});
|
|
635
698
|
|
|
@@ -658,9 +721,9 @@ export function App({
|
|
|
658
721
|
|
|
659
722
|
return;
|
|
660
723
|
}
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
if (prototypeComposing) return;
|
|
724
|
+
// A compose textarea owns the keyboard while open: let it receive the typed note instead of the
|
|
725
|
+
// global keymap acting on each letter (the prototype, and the bare-shell welcome playground).
|
|
726
|
+
if (prototypeComposing || welcomeComposing) return;
|
|
664
727
|
if (menuDialog === "settings") return void handleSettingsKey(key.name);
|
|
665
728
|
if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
|
|
666
729
|
// the toast is non-modal: escape only dismisses it when nothing else owns
|
|
@@ -702,6 +765,9 @@ export function App({
|
|
|
702
765
|
dispatch(intent);
|
|
703
766
|
});
|
|
704
767
|
|
|
768
|
+
// after every keyboard hook above, so their subscriptions precede the signal
|
|
769
|
+
useReadySignal(usableScreenReached(error, session, inbox), onReady);
|
|
770
|
+
|
|
705
771
|
// ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
|
|
706
772
|
// reused by the inbox and by plan/diff review so the two never drift ──
|
|
707
773
|
const menuChrome = (
|
|
@@ -735,6 +801,8 @@ export function App({
|
|
|
735
801
|
pinnedIds={pinnedIds}
|
|
736
802
|
onPin={togglePin}
|
|
737
803
|
onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
|
|
804
|
+
quickActions={quickActions}
|
|
805
|
+
onWelcomeComposingChange={setWelcomeComposing}
|
|
738
806
|
/>
|
|
739
807
|
) : (
|
|
740
808
|
<ConnectingScreen theme={theme} />
|
|
@@ -776,7 +844,7 @@ export function App({
|
|
|
776
844
|
session: activeSession,
|
|
777
845
|
isOwner,
|
|
778
846
|
isDiff,
|
|
779
|
-
|
|
847
|
+
isPixelPrototype,
|
|
780
848
|
resolved,
|
|
781
849
|
menuDialog,
|
|
782
850
|
resolvedIds,
|
|
@@ -812,7 +880,7 @@ export function App({
|
|
|
812
880
|
onOpenMenu={() => setMenuDialog("settings")}
|
|
813
881
|
threadsPanel={
|
|
814
882
|
<scrollbox style={{ flexGrow: 1 }} focused={false}>
|
|
815
|
-
<
|
|
883
|
+
<ThreadTree
|
|
816
884
|
rows={grouped.rows}
|
|
817
885
|
cursor={inboxCursor}
|
|
818
886
|
activeId={activeSession.id}
|
|
@@ -843,10 +911,9 @@ export function App({
|
|
|
843
911
|
threadPanel={
|
|
844
912
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
845
913
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
846
|
-
{
|
|
847
|
-
<
|
|
914
|
+
{isPixelPrototype ? (
|
|
915
|
+
<PrototypePixels
|
|
848
916
|
prototypePath={prototypePath}
|
|
849
|
-
quickActions={quickActions}
|
|
850
917
|
canComment={prototypeCanComment}
|
|
851
918
|
onCommentElement={onCommentPrototype}
|
|
852
919
|
onComposingChange={setPrototypeComposing}
|
|
@@ -930,6 +997,7 @@ export function App({
|
|
|
930
997
|
session: activeSession,
|
|
931
998
|
quickActions,
|
|
932
999
|
observer,
|
|
1000
|
+
commentsEnabled: true,
|
|
933
1001
|
resolved,
|
|
934
1002
|
suspended: threadViewSuspended || !groupFocused,
|
|
935
1003
|
onComposingChange: setThreadComposing,
|
|
@@ -942,6 +1010,8 @@ export function App({
|
|
|
942
1010
|
onCursorChange: setCursor,
|
|
943
1011
|
focusedAnnotationId,
|
|
944
1012
|
onFocusAnnotation: setFocusedAnnotationId,
|
|
1013
|
+
// a diff thread's Changes view is the artifact itself; any other thread's is the
|
|
1014
|
+
// live working-tree diff, so its notes carry a file target and anchor there
|
|
945
1015
|
onAnnotate: (span, body) =>
|
|
946
1016
|
void controller.annotate(
|
|
947
1017
|
"comment",
|
|
@@ -950,6 +1020,7 @@ export function App({
|
|
|
950
1020
|
span.end.char,
|
|
951
1021
|
body,
|
|
952
1022
|
span.end.blockIndex,
|
|
1023
|
+
isDiff ? { kind: "artifact" } : { kind: "file", path: "", rev: "worktree" },
|
|
953
1024
|
),
|
|
954
1025
|
onReply: (rootAnnotationId, body) =>
|
|
955
1026
|
void controller.reply(rootAnnotationId, body),
|
|
@@ -962,6 +1033,9 @@ export function App({
|
|
|
962
1033
|
split={diffView === "split" && workbench.zoomed}
|
|
963
1034
|
dimmed={walking}
|
|
964
1035
|
readFile={(path) => controller.repoReadFile(path)}
|
|
1036
|
+
onAddFileComment={(path, anchor, body) =>
|
|
1037
|
+
void controller.addComment(anchor, { kind: "file", path, rev: "worktree" }, body)
|
|
1038
|
+
}
|
|
965
1039
|
theme={theme}
|
|
966
1040
|
/>
|
|
967
1041
|
)}
|
|
@@ -974,8 +1048,9 @@ export function App({
|
|
|
974
1048
|
loadChanges={() => controller.repoChanges()}
|
|
975
1049
|
loadProjectFiles={() => controller.repoFiles()}
|
|
976
1050
|
reloadKey={activeSession.id}
|
|
977
|
-
//
|
|
978
|
-
|
|
1051
|
+
// the Changes navigator always opens a changed file as a diff - a diff review shows its
|
|
1052
|
+
// captured snapshot, every other thread the live working-tree diff
|
|
1053
|
+
onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
|
|
979
1054
|
onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
|
|
980
1055
|
commentCounts={diffCommentCounts}
|
|
981
1056
|
theme={theme}
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -5,7 +5,6 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import React from "react";
|
|
8
|
-
import { testRender } from "@opentui/react/test-utils";
|
|
9
8
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
9
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
10
|
import { App } from "./App";
|
|
@@ -14,6 +13,7 @@ import {
|
|
|
14
13
|
isolateUserConfig,
|
|
15
14
|
press,
|
|
16
15
|
pressKey,
|
|
16
|
+
renderReadyApp,
|
|
17
17
|
waitForState,
|
|
18
18
|
waitForText,
|
|
19
19
|
waitForTextGone,
|
|
@@ -66,7 +66,7 @@ afterEach(() => {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
async function renderApp() {
|
|
69
|
-
const setup = await
|
|
69
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
70
70
|
width: 120,
|
|
71
71
|
height: 34,
|
|
72
72
|
});
|
|
@@ -15,7 +15,16 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
15
15
|
import { DaemonServer } from "@cueloop/daemon";
|
|
16
16
|
import type { ReviewSession } from "@cueloop/schema";
|
|
17
17
|
import { App } from "./App";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
dragText,
|
|
20
|
+
isolateUserConfig,
|
|
21
|
+
locateText,
|
|
22
|
+
pressKey,
|
|
23
|
+
typeText,
|
|
24
|
+
waitForState,
|
|
25
|
+
waitForText,
|
|
26
|
+
renderReadyApp,
|
|
27
|
+
} from "./test-support";
|
|
19
28
|
import { NERD } from "./components/primitives/icons";
|
|
20
29
|
|
|
21
30
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
@@ -73,7 +82,7 @@ afterEach(() => {
|
|
|
73
82
|
});
|
|
74
83
|
|
|
75
84
|
async function renderApp() {
|
|
76
|
-
const setup = await
|
|
85
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
77
86
|
width: 160,
|
|
78
87
|
height: 20,
|
|
79
88
|
});
|
|
@@ -183,17 +192,17 @@ describe("the four-pane workbench", () => {
|
|
|
183
192
|
expect(diffToggleColumn(setup)).toBeGreaterThan(0);
|
|
184
193
|
});
|
|
185
194
|
|
|
186
|
-
test("the right-sidebar toggle
|
|
195
|
+
test("the right-sidebar toggle closes the region and its header toggle reopens it", async () => {
|
|
187
196
|
const setup = await renderApp();
|
|
188
197
|
|
|
189
198
|
await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
|
|
190
199
|
await waitForState(setup, () => !setup.captureCharFrame().includes("store.ts"));
|
|
191
200
|
|
|
192
|
-
// the right region is closed: no Changes editor, no Project tree toggles
|
|
201
|
+
// the right region is closed: no Changes editor, no Project tree toggles, no gutter
|
|
193
202
|
expect(setup.captureCharFrame()).not.toContain("store.ts");
|
|
194
203
|
expect(diffToggleColumn(setup)).toBe(-1);
|
|
195
204
|
|
|
196
|
-
// the
|
|
205
|
+
// the reopen toggle (outline/off variant) rides the thread header; clicking it reopens the region
|
|
197
206
|
await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
|
|
198
207
|
await waitForText(setup, "store.ts");
|
|
199
208
|
expect(diffToggleColumn(setup)).toBeGreaterThan(0);
|
|
@@ -271,7 +280,7 @@ describe("the four-pane workbench", () => {
|
|
|
271
280
|
/** The bare-launch (no thread) shell is the same four panes as a thread: the Thread pane waits in its
|
|
272
281
|
* empty state and a disposable Welcome tab rides in the Changes editor. The right-region toggles behave
|
|
273
282
|
* like the thread shell, dismissing Welcome collapses the editor without stranding an empty screen, and
|
|
274
|
-
*
|
|
283
|
+
* a closed right region moves its reopen toggle into the header rather than leaving a gutter. */
|
|
275
284
|
describe("the bare-launch welcome shell", () => {
|
|
276
285
|
let welcomeHome: string;
|
|
277
286
|
let welcomeRepo: string;
|
|
@@ -313,6 +322,38 @@ describe("the bare-launch welcome shell", () => {
|
|
|
313
322
|
expect(setup.captureCharFrame()).toContain("README.md");
|
|
314
323
|
});
|
|
315
324
|
|
|
325
|
+
test("the first comment in the bare shell creates and persists a per-repo workbench thread", async () => {
|
|
326
|
+
const setup = await renderWelcome();
|
|
327
|
+
|
|
328
|
+
// open a project file in the bare shell (no thread yet - nothing on disk)
|
|
329
|
+
expect(welcomeServer.core.sessionList().length).toBe(0);
|
|
330
|
+
await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
|
|
331
|
+
await waitForText(setup, "README.md");
|
|
332
|
+
const readme = locateText(setup, "README.md");
|
|
333
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
334
|
+
await waitForText(setup, "edited in the working tree");
|
|
335
|
+
|
|
336
|
+
// comment on a line - the first note find-or-creates the workbench thread
|
|
337
|
+
const line = "edited in the working tree";
|
|
338
|
+
await dragText(setup, line, line, line.length);
|
|
339
|
+
await typeText(setup, "workbench note");
|
|
340
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
341
|
+
|
|
342
|
+
// the daemon persisted one per-repo workbench thread carrying the note
|
|
343
|
+
await waitForState(setup, () =>
|
|
344
|
+
welcomeServer.core.sessionList().some((thread) => thread.artifact.meta.workbench === true),
|
|
345
|
+
);
|
|
346
|
+
const workbench = welcomeServer.core
|
|
347
|
+
.sessionList()
|
|
348
|
+
.find((thread) => thread.artifact.meta.workbench === true)!;
|
|
349
|
+
expect(workbench.workspace.rootCommit).toBeTruthy();
|
|
350
|
+
await waitForState(setup, () =>
|
|
351
|
+
welcomeServer.core
|
|
352
|
+
.sessionGet(workbench.id)
|
|
353
|
+
.annotations.some((note) => note.body.includes("workbench note")),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
316
357
|
test("the changes tree lists the launch repo's working-tree changes", async () => {
|
|
317
358
|
const setup = await renderWelcome();
|
|
318
359
|
|
|
@@ -324,12 +365,42 @@ describe("the bare-launch welcome shell", () => {
|
|
|
324
365
|
test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
|
|
325
366
|
const setup = await renderWelcome();
|
|
326
367
|
|
|
368
|
+
// the Welcome playground measures its width before it paints, so wait for its copy
|
|
369
|
+
await waitForText(setup, "Getting started");
|
|
327
370
|
const frame = setup.captureCharFrame();
|
|
328
371
|
// the getting-started surface is an editor tab, not the thread pane
|
|
329
372
|
expect(frame.split("\n")[HEADER_ROW]!).toContain("Welcome");
|
|
330
|
-
expect(frame).toContain("
|
|
373
|
+
expect(frame).toContain("Getting started");
|
|
331
374
|
// the Thread pane shows its empty state until a thread is opened
|
|
332
|
-
expect(frame).toContain("
|
|
375
|
+
expect(frame).toContain("no threads");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("typing in the welcome composer never reaches the inbox keys", async () => {
|
|
379
|
+
// a pending thread in the sidebar so the inbox keys (delete, open) have a target to act on
|
|
380
|
+
welcomeServer.core.sessionCreate({
|
|
381
|
+
workspace: { repoRoot: welcomeRepo, branch: "main" },
|
|
382
|
+
artifact: { type: "plan", content: "# Seeded\n", meta: { title: "Seeded Plan" } },
|
|
383
|
+
});
|
|
384
|
+
const setup = await testRender(
|
|
385
|
+
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
|
|
386
|
+
{ width: 160, height: 28 },
|
|
387
|
+
);
|
|
388
|
+
await waitForText(setup, "quick brown fox");
|
|
389
|
+
|
|
390
|
+
// open the playground composer, then type letters that are also inbox keys (j, k, d), one at a
|
|
391
|
+
// time as a person would - each keystroke settles a frame, the way real input arrives
|
|
392
|
+
await dragText(setup, "quick brown fox", "quick brown fox", "quick brown fox".length);
|
|
393
|
+
for (const character of "note: jkd") await typeText(setup, character);
|
|
394
|
+
await waitForText(setup, "note: jkd");
|
|
395
|
+
|
|
396
|
+
// the letters landed in the note, not the inbox: no delete confirmation opened
|
|
397
|
+
expect(setup.captureCharFrame()).not.toContain("This removes the plan");
|
|
398
|
+
|
|
399
|
+
// Cmd+Enter saves the note; it must not dispatch openSession and replace the playground
|
|
400
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
401
|
+
expect(setup.captureCharFrame()).toContain("Getting started");
|
|
402
|
+
|
|
403
|
+
setup.renderer.destroy();
|
|
333
404
|
});
|
|
334
405
|
|
|
335
406
|
test("dismissing the Welcome tab collapses the editor without stranding an empty screen", async () => {
|
|
@@ -340,13 +411,13 @@ describe("the bare-launch welcome shell", () => {
|
|
|
340
411
|
await setup.mockMouse.click(welcome.column + 8, welcome.row);
|
|
341
412
|
await waitForState(
|
|
342
413
|
setup,
|
|
343
|
-
() => !setup.captureCharFrame().includes("
|
|
414
|
+
() => !setup.captureCharFrame().includes("comment on agent-authored work"),
|
|
344
415
|
);
|
|
345
416
|
|
|
346
417
|
// the Threads sidebar and the Thread empty state remain - never a blank shell
|
|
347
418
|
const frame = setup.captureCharFrame();
|
|
348
419
|
expect(frame).toContain("cueloop");
|
|
349
|
-
expect(frame).toContain("
|
|
420
|
+
expect(frame).toContain("no threads");
|
|
350
421
|
});
|
|
351
422
|
|
|
352
423
|
test("the changed-files and tree toggles switch navigator mode without collapsing", async () => {
|
|
@@ -364,20 +435,20 @@ describe("the bare-launch welcome shell", () => {
|
|
|
364
435
|
await waitForState(setup, () => !setup.captureCharFrame().includes("src"));
|
|
365
436
|
expect(diffToggleColumn(setup)).toBeGreaterThan(0);
|
|
366
437
|
|
|
367
|
-
// the sidebar toggle
|
|
438
|
+
// the sidebar toggle closes the region
|
|
368
439
|
await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
|
|
369
440
|
await waitForState(setup, () => diffToggleColumn(setup) === -1);
|
|
370
441
|
});
|
|
371
442
|
|
|
372
|
-
test("
|
|
443
|
+
test("a closed region draws its reopen divider in the header only, not full-height", async () => {
|
|
373
444
|
const setup = await renderWelcome();
|
|
374
445
|
|
|
375
|
-
//
|
|
446
|
+
// close the right region so only the header toggle remains
|
|
376
447
|
await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
|
|
377
448
|
await waitForState(setup, () => diffToggleColumn(setup) === -1);
|
|
378
449
|
|
|
379
450
|
const lines = setup.captureCharFrame().split("\n");
|
|
380
|
-
// the
|
|
451
|
+
// the reopen toggle's divider sits at the far right; find its column on the header row
|
|
381
452
|
const headerDivider = lines[HEADER_ROW]!.lastIndexOf("│");
|
|
382
453
|
expect(headerDivider).toBeGreaterThan(0);
|
|
383
454
|
// that column stays clear below the two-row header - no full-height column rule
|