@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79
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 +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
|
@@ -37,9 +37,9 @@ import {
|
|
|
37
37
|
} from "./thread-selection";
|
|
38
38
|
import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
|
|
39
39
|
import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
|
|
40
|
-
import { matchesLeader } from "./thread-chords";
|
|
41
40
|
import {
|
|
42
41
|
activeSlashToken,
|
|
42
|
+
insertSlashItem,
|
|
43
43
|
isStandaloneSlashQuery,
|
|
44
44
|
mergeSlashItems,
|
|
45
45
|
slashFilter,
|
|
@@ -106,8 +106,8 @@ export interface AnnotationSurfaceOptions {
|
|
|
106
106
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
107
107
|
/** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
|
|
108
108
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
/** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
|
|
110
|
+
onNavCommand?: (key: KeyEvent) => boolean;
|
|
111
111
|
onExit: () => void;
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -118,6 +118,8 @@ export interface AnnotationSurface {
|
|
|
118
118
|
cursor: number;
|
|
119
119
|
head: TextPosition;
|
|
120
120
|
compose: ComposeState | null;
|
|
121
|
+
/** True while the surface is in nav mode: bare keys act as commands, typing does not compose. */
|
|
122
|
+
navMode: boolean;
|
|
121
123
|
focusedDiscussion: string | null;
|
|
122
124
|
/** The block to keep in view: an opening card, a focused discussion, else the caret. */
|
|
123
125
|
revealBlockIndex: number;
|
|
@@ -182,8 +184,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
182
184
|
onReply,
|
|
183
185
|
onUpdateAnnotation,
|
|
184
186
|
resolveAuthorLabel,
|
|
185
|
-
|
|
186
|
-
onLeaderCommand,
|
|
187
|
+
onNavCommand,
|
|
187
188
|
onExit,
|
|
188
189
|
} = options;
|
|
189
190
|
const palette = annotationPaletteFor(tokens);
|
|
@@ -198,7 +199,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
198
199
|
return { head: start, anchor: start };
|
|
199
200
|
});
|
|
200
201
|
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
201
|
-
const
|
|
202
|
+
const [navMode, setNavModeState] = useState(false);
|
|
203
|
+
const navModeRef = useRef(false);
|
|
204
|
+
const setNavMode = (value: boolean): void => {
|
|
205
|
+
navModeRef.current = value;
|
|
206
|
+
setNavModeState(value);
|
|
207
|
+
};
|
|
202
208
|
|
|
203
209
|
useEffect(() => {
|
|
204
210
|
onComposingChange?.(compose !== null);
|
|
@@ -243,6 +249,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
243
249
|
};
|
|
244
250
|
const [folded, setFolded] = useState<Set<string>>(new Set());
|
|
245
251
|
const [composeText, setComposeText] = useState("");
|
|
252
|
+
const composeTextRef = useRef("");
|
|
253
|
+
const setDraft = (text: string): void => {
|
|
254
|
+
composeTextRef.current = text;
|
|
255
|
+
setComposeText(text);
|
|
256
|
+
};
|
|
246
257
|
const [caretOffset, setCaretOffset] = useState(0);
|
|
247
258
|
const [slashIndex, setSlashIndex] = useState(0);
|
|
248
259
|
const composerReady = useRef(false);
|
|
@@ -314,14 +325,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
314
325
|
composerReady.current = false;
|
|
315
326
|
composeRef.current = state;
|
|
316
327
|
setCompose(state);
|
|
317
|
-
|
|
328
|
+
setDraft(state.seed);
|
|
318
329
|
setCaretOffset(state.seed.length);
|
|
319
330
|
setSlashIndex(0);
|
|
320
331
|
};
|
|
321
332
|
const closeCompose = (): void => {
|
|
322
333
|
composeRef.current = null;
|
|
323
334
|
setCompose(null);
|
|
324
|
-
|
|
335
|
+
setDraft("");
|
|
325
336
|
setCaretOffset(0);
|
|
326
337
|
};
|
|
327
338
|
const skills = useContext(SlashSkillsContext);
|
|
@@ -451,14 +462,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
451
462
|
return true;
|
|
452
463
|
}
|
|
453
464
|
if (key.name === "return" || key.name === "tab") {
|
|
454
|
-
const
|
|
455
|
-
const cut = caretOffset - token.length;
|
|
456
|
-
const insertion = `/${slashItems[selected]!.name} `;
|
|
457
|
-
const seed = composeText.slice(0, cut) + insertion + composeText.slice(caretOffset);
|
|
465
|
+
const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
|
|
458
466
|
|
|
459
|
-
openCompose({ ...activeCompose, seed });
|
|
460
|
-
|
|
461
|
-
setCaretOffset(cut + insertion.length);
|
|
467
|
+
openCompose({ ...activeCompose, seed: inserted.text });
|
|
468
|
+
setCaretOffset(inserted.caret);
|
|
462
469
|
|
|
463
470
|
return true;
|
|
464
471
|
}
|
|
@@ -473,7 +480,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
473
480
|
const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
|
|
474
481
|
|
|
475
482
|
composeRef.current = grown;
|
|
476
|
-
|
|
483
|
+
setDraft(grown.seed);
|
|
477
484
|
setCaretOffset(grown.seed.length);
|
|
478
485
|
|
|
479
486
|
return setCompose(grown);
|
|
@@ -484,7 +491,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
484
491
|
const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
|
|
485
492
|
|
|
486
493
|
composeRef.current = grown;
|
|
487
|
-
|
|
494
|
+
setDraft(grown.seed);
|
|
488
495
|
setCaretOffset(grown.seed.length);
|
|
489
496
|
setCompose(grown);
|
|
490
497
|
}
|
|
@@ -503,7 +510,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
503
510
|
// (an edit of an existing comment is never deleted this way)
|
|
504
511
|
if (
|
|
505
512
|
key.name === "backspace" &&
|
|
506
|
-
|
|
513
|
+
composeTextRef.current.length === 0 &&
|
|
507
514
|
activeCompose.editAnnotationId === null
|
|
508
515
|
) {
|
|
509
516
|
return closeCompose();
|
|
@@ -512,7 +519,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
512
519
|
if (!composerReady.current) handlePremountKey(key, activeCompose);
|
|
513
520
|
};
|
|
514
521
|
|
|
515
|
-
/**
|
|
522
|
+
/** Fold or unfold every discussion sitting on the caret's block (nav-mode z). */
|
|
523
|
+
const toggleFoldAtCursor = (): void => {
|
|
524
|
+
setFolded((current) => {
|
|
525
|
+
const next = new Set(current);
|
|
526
|
+
|
|
527
|
+
for (const discussion of discussions) {
|
|
528
|
+
if (discussion.blockIndex !== cursor) continue;
|
|
529
|
+
if (next.has(discussion.key)) next.delete(discussion.key);
|
|
530
|
+
else next.add(discussion.key);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return next;
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/** m / cmd+[ / cmd+] / return - true when the key was a discussion primitive. */
|
|
516
538
|
const handleDiscussionVerb = (key: KeyEvent): boolean => {
|
|
517
539
|
// comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
|
|
518
540
|
if (key.name === "m" && (key.super || key.meta || key.option)) {
|
|
@@ -536,21 +558,6 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
536
558
|
|
|
537
559
|
return true;
|
|
538
560
|
}
|
|
539
|
-
if (key.name === "tab") {
|
|
540
|
-
setFolded((current) => {
|
|
541
|
-
const next = new Set(current);
|
|
542
|
-
|
|
543
|
-
for (const discussion of discussions) {
|
|
544
|
-
if (discussion.blockIndex !== cursor) continue;
|
|
545
|
-
if (next.has(discussion.key)) next.delete(discussion.key);
|
|
546
|
-
else next.add(discussion.key);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return next;
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
return true;
|
|
553
|
-
}
|
|
554
561
|
// enter replies into the focused discussion, else the cursor block's discussion
|
|
555
562
|
if (key.name === "return") {
|
|
556
563
|
const replyTarget =
|
|
@@ -666,23 +673,25 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
666
673
|
|
|
667
674
|
if (activeCompose) return handleComposeKey(key, activeCompose);
|
|
668
675
|
if (key.name === "escape") {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
if (focusedDiscussion !== null) return setFocusedDiscussion(null);
|
|
676
|
+
// from type mode esc only enters nav, so a held mark survives for `c`
|
|
677
|
+
if (!navModeRef.current) return setNavMode(true);
|
|
678
|
+
if (focusedDiscussion !== null) setFocusedDiscussion(null);
|
|
675
679
|
|
|
676
680
|
return collapseCaret();
|
|
677
681
|
}
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
682
|
+
if (navModeRef.current) {
|
|
683
|
+
if (handleCaretKey(key)) return;
|
|
684
|
+
if (key.name === "z") return toggleFoldAtCursor();
|
|
685
|
+
if (onNavCommand?.(key)) return;
|
|
686
|
+
if (key.name === "c") {
|
|
687
|
+
setNavMode(false);
|
|
688
|
+
|
|
689
|
+
return openNewCompose("");
|
|
690
|
+
}
|
|
691
|
+
if (printableSequence(key)) {
|
|
692
|
+
setNavMode(false);
|
|
693
|
+
startTyping(key);
|
|
694
|
+
}
|
|
686
695
|
|
|
687
696
|
return;
|
|
688
697
|
}
|
|
@@ -704,7 +713,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
704
713
|
onSave={saveComment}
|
|
705
714
|
onReady={() => (composerReady.current = true)}
|
|
706
715
|
onInput={(text, caret) => {
|
|
707
|
-
|
|
716
|
+
setDraft(text);
|
|
708
717
|
setCaretOffset(caret);
|
|
709
718
|
}}
|
|
710
719
|
/>
|
|
@@ -799,9 +808,36 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
799
808
|
const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
|
|
800
809
|
const nodes: React.ReactNode[] = [];
|
|
801
810
|
const composeHere = compose && compose.blockIndex === blockIndex;
|
|
811
|
+
const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
|
|
812
|
+
const composeAnchoredHere =
|
|
813
|
+
newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
|
|
814
|
+
const composeStart = compose?.span?.start;
|
|
815
|
+
|
|
816
|
+
const pushComposeCard = (): void => {
|
|
817
|
+
nodes.push(
|
|
818
|
+
<DiscussionCard
|
|
819
|
+
key="compose-new"
|
|
820
|
+
tokens={tokens}
|
|
821
|
+
segments={[{ color: palette.cardEdge, node: composerNode }]}
|
|
822
|
+
/>,
|
|
823
|
+
);
|
|
824
|
+
nodes.push(paletteNode);
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
// the draft sorts in by its span start, the same order discussionsFrom lands it on save, so a
|
|
828
|
+
// new comment sits in its final position while typing instead of appending to the stack
|
|
829
|
+
let composePending = composeAnchoredHere;
|
|
802
830
|
|
|
803
831
|
for (const discussion of discussions) {
|
|
804
832
|
if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
|
|
833
|
+
if (
|
|
834
|
+
composePending &&
|
|
835
|
+
composeStart &&
|
|
836
|
+
comparePositions(composeStart, discussion.span.start) < 0
|
|
837
|
+
) {
|
|
838
|
+
pushComposeCard();
|
|
839
|
+
composePending = false;
|
|
840
|
+
}
|
|
805
841
|
const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
|
|
806
842
|
|
|
807
843
|
if (folded.has(discussion.key) && !composingHere) {
|
|
@@ -811,20 +847,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
811
847
|
nodes.push(discussionCardFor(discussion, composingHere));
|
|
812
848
|
if (composingHere) nodes.push(paletteNode);
|
|
813
849
|
}
|
|
814
|
-
if (
|
|
815
|
-
const anchoredHere = compose.span ? endsInLine(compose.span.end.char) : isLastLine;
|
|
816
|
-
|
|
817
|
-
if (anchoredHere) {
|
|
818
|
-
nodes.push(
|
|
819
|
-
<DiscussionCard
|
|
820
|
-
key="compose-new"
|
|
821
|
-
tokens={tokens}
|
|
822
|
-
segments={[{ color: palette.cardEdge, node: composerNode }]}
|
|
823
|
-
/>,
|
|
824
|
-
);
|
|
825
|
-
nodes.push(paletteNode);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
850
|
+
if (composePending) pushComposeCard();
|
|
828
851
|
|
|
829
852
|
return nodes;
|
|
830
853
|
};
|
|
@@ -849,6 +872,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
849
872
|
const pressed = positionAt(allGeometry(), event.x, event.y);
|
|
850
873
|
|
|
851
874
|
if (!pressed || !source.annotatable(pressed.blockIndex)) return;
|
|
875
|
+
setNavMode(false);
|
|
852
876
|
const stamp = { time: Date.now(), x: event.x, y: event.y };
|
|
853
877
|
const wordMode = isDoubleClick(lastClick.current, stamp);
|
|
854
878
|
|
|
@@ -876,6 +900,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
876
900
|
cursor,
|
|
877
901
|
head,
|
|
878
902
|
compose,
|
|
903
|
+
navMode,
|
|
879
904
|
focusedDiscussion,
|
|
880
905
|
revealBlockIndex,
|
|
881
906
|
spanQuote,
|
|
@@ -35,11 +35,14 @@ export interface SettingsDialogModel {
|
|
|
35
35
|
cycleSetting: (rowKey: string) => void;
|
|
36
36
|
handleSettingsKey: (name: string) => void;
|
|
37
37
|
onCategorySelect: (categoryId: string) => void;
|
|
38
|
+
/** Open the settings dialog focused on the left nav, from a fresh state. */
|
|
39
|
+
openSettings: () => void;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
const DOWN_KEYS = new Set(["j", "down"]);
|
|
41
43
|
const UP_KEYS = new Set(["k", "up"]);
|
|
42
|
-
const ENTER_BODY_KEYS = new Set(["l", "tab", "return"]);
|
|
44
|
+
const ENTER_BODY_KEYS = new Set(["l", "right", "tab", "return"]);
|
|
45
|
+
const BACK_TO_NAV_KEYS = new Set(["h", "left", "tab"]);
|
|
43
46
|
const ACTIVATE_KEYS = new Set(["return", "space", "l"]);
|
|
44
47
|
|
|
45
48
|
function moveNavZone(
|
|
@@ -64,7 +67,7 @@ function moveNavZone(
|
|
|
64
67
|
function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
|
|
65
68
|
if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
|
|
66
69
|
if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
|
|
67
|
-
if (name
|
|
70
|
+
if (BACK_TO_NAV_KEYS.has(name)) return { ...nav, zone: "nav" };
|
|
68
71
|
|
|
69
72
|
return null;
|
|
70
73
|
}
|
|
@@ -108,10 +111,11 @@ export function useSettingsDialog(params: {
|
|
|
108
111
|
onRenameDisplayName,
|
|
109
112
|
} = params;
|
|
110
113
|
|
|
114
|
+
// open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
|
|
111
115
|
const [settingsNav, setSettingsNav] = useState<SettingsNav>({
|
|
112
116
|
categoryId: "general",
|
|
113
117
|
rowIndex: 0,
|
|
114
|
-
zone: "
|
|
118
|
+
zone: "nav",
|
|
115
119
|
});
|
|
116
120
|
const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
|
|
117
121
|
const [actionsExpandedField, setActionsExpandedField] = useState<"prompt" | "metadata">("prompt");
|
|
@@ -247,6 +251,12 @@ export function useSettingsDialog(params: {
|
|
|
247
251
|
}
|
|
248
252
|
};
|
|
249
253
|
|
|
254
|
+
const openSettings = (): void => {
|
|
255
|
+
setSettingsNav({ categoryId: "general", rowIndex: 0, zone: "nav" });
|
|
256
|
+
setActionsExpandedIndex(null);
|
|
257
|
+
setMenuDialog("settings");
|
|
258
|
+
};
|
|
259
|
+
|
|
250
260
|
const onCategorySelect = (categoryId: string): void => {
|
|
251
261
|
// ignore a select that names no real category, so the key handler never dereferences nothing
|
|
252
262
|
if (!settingsCategories.some((category) => category.id === categoryId)) return;
|
|
@@ -305,5 +315,6 @@ export function useSettingsDialog(params: {
|
|
|
305
315
|
cycleSetting,
|
|
306
316
|
handleSettingsKey,
|
|
307
317
|
onCategorySelect,
|
|
318
|
+
openSettings,
|
|
308
319
|
};
|
|
309
320
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread pane's inline edit mode: owns whether the native markdown editor is
|
|
3
|
+
* open over the body, the editor element itself, and the open and exit handles.
|
|
4
|
+
* Opening is gated to an editable thread (the caller passes canEdit - owner,
|
|
5
|
+
* unresolved, not a diff); exiting saves the edited body as the working copy
|
|
6
|
+
* through the controller, so leaving the editor never loses work. The header
|
|
7
|
+
* edit/normal toggle exits through requestExit; switching threads closes the
|
|
8
|
+
* editor. Rendering the editor lives here too, so the pane pulls in one concept
|
|
9
|
+
* rather than wiring the component and its handle ref by hand.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useRef, useState } from "react";
|
|
13
|
+
import type { Theme } from "./theme";
|
|
14
|
+
import type { ReviewController } from "./thread-controller";
|
|
15
|
+
import { MarkdownThreadEditor, type MarkdownEditorHandle } from "./components/MarkdownThreadEditor";
|
|
16
|
+
|
|
17
|
+
export interface ThreadBodyEditing {
|
|
18
|
+
/** True while the inline markdown editor owns the thread pane and its keys. */
|
|
19
|
+
editing: boolean;
|
|
20
|
+
/** Open the editor when the active thread is editable; a no-op otherwise. */
|
|
21
|
+
openEditor: () => void;
|
|
22
|
+
/** Save the current text and close - the header edit/normal toggle's exit. */
|
|
23
|
+
requestExit: () => void;
|
|
24
|
+
/** The editor element for the thread pane, bound to the working copy and theme. */
|
|
25
|
+
renderEditor: (theme: Theme) => React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useThreadBodyEditing(options: {
|
|
29
|
+
controller: ReviewController;
|
|
30
|
+
sessionId: string | undefined;
|
|
31
|
+
canEdit: boolean;
|
|
32
|
+
}): ThreadBodyEditing {
|
|
33
|
+
const { controller, sessionId, canEdit } = options;
|
|
34
|
+
// key editing by thread id so a thread switch closes the editor with no effect or ref
|
|
35
|
+
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
|
36
|
+
const handleRef = useRef<MarkdownEditorHandle | null>(null);
|
|
37
|
+
const editing = editingSessionId !== null && editingSessionId === sessionId;
|
|
38
|
+
const exitEditor = (text: string): void => {
|
|
39
|
+
controller.saveEditedBody(text);
|
|
40
|
+
setEditingSessionId(null);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
editing,
|
|
45
|
+
openEditor: () => {
|
|
46
|
+
if (canEdit && sessionId !== undefined) setEditingSessionId(sessionId);
|
|
47
|
+
},
|
|
48
|
+
requestExit: () => handleRef.current?.requestExit(),
|
|
49
|
+
renderEditor: (theme) => (
|
|
50
|
+
<MarkdownThreadEditor
|
|
51
|
+
ref={handleRef}
|
|
52
|
+
initialText={controller.working()}
|
|
53
|
+
theme={theme}
|
|
54
|
+
onExitEditor={exitEditor}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/view-plan.test.ts
CHANGED
|
@@ -9,10 +9,14 @@ import {
|
|
|
9
9
|
renderedOffsetAtOrAfter,
|
|
10
10
|
renderedOffsetAtOrBefore,
|
|
11
11
|
renderedOffsetFor,
|
|
12
|
+
renderedSpanToWork,
|
|
13
|
+
renderedStyleRuns,
|
|
14
|
+
renderedText,
|
|
12
15
|
safeLinkHref,
|
|
13
16
|
spanFromRange,
|
|
14
17
|
spanKey,
|
|
15
18
|
startSpan,
|
|
19
|
+
styledRunsFor,
|
|
16
20
|
workRangeForRendered,
|
|
17
21
|
} from "./view-plan";
|
|
18
22
|
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
@@ -273,9 +277,9 @@ describe("rendered/work offset mapping", () => {
|
|
|
273
277
|
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
274
278
|
|
|
275
279
|
// Assert
|
|
276
|
-
const
|
|
280
|
+
const renderedString = runs.map((run) => run.text).join("");
|
|
277
281
|
|
|
278
|
-
expect(
|
|
282
|
+
expect(renderedString.slice(rendered, rendered + "words".length)).toBe("words");
|
|
279
283
|
// reading a rendered selection over "words" recovers the work range
|
|
280
284
|
expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
|
|
281
285
|
start: wordsAt,
|
|
@@ -287,11 +291,11 @@ describe("rendered/work offset mapping", () => {
|
|
|
287
291
|
// Arrange
|
|
288
292
|
const block = buildDisplay("old words\n", "new words\n")[0]!;
|
|
289
293
|
const runs = blockRuns(block, true);
|
|
290
|
-
const
|
|
294
|
+
const renderedString = runs.map((run) => run.text).join("");
|
|
291
295
|
|
|
292
296
|
// Act
|
|
293
297
|
// select everything rendered: the work range is the whole work text
|
|
294
|
-
const range = workRangeForRendered(runs, 0,
|
|
298
|
+
const range = workRangeForRendered(runs, 0, renderedString.length)!;
|
|
295
299
|
|
|
296
300
|
// Assert
|
|
297
301
|
expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
|
|
@@ -319,9 +323,9 @@ describe("rendered/work offset mapping", () => {
|
|
|
319
323
|
const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
|
|
320
324
|
|
|
321
325
|
// Assert
|
|
322
|
-
const
|
|
326
|
+
const renderedString = runs.map((run) => run.text).join("");
|
|
323
327
|
|
|
324
|
-
expect(
|
|
328
|
+
expect(renderedString.slice(renderedStart, renderedEnd + 1)).toBe("very");
|
|
325
329
|
// exact hits behave like renderedOffsetFor
|
|
326
330
|
expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
|
|
327
331
|
expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
|
|
@@ -404,3 +408,49 @@ describe("cut / restore round-trip", () => {
|
|
|
404
408
|
expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
|
|
405
409
|
});
|
|
406
410
|
});
|
|
411
|
+
|
|
412
|
+
describe("rendered-text projection (markers concealed)", () => {
|
|
413
|
+
const linkedBlock = () => buildDisplay("Use the `flag` and see [docs](https://x.dev).")[0]!;
|
|
414
|
+
|
|
415
|
+
test("renderedText drops inline markers, keeping visible characters", () => {
|
|
416
|
+
expect(renderedText(linkedBlock())).toBe("Use the flag and see docs.");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("renderedText leaves a table block's source verbatim", () => {
|
|
420
|
+
const table = buildDisplay("| a | b |\n| --- | --- |\n| 1 | 2 |")[0]!;
|
|
421
|
+
|
|
422
|
+
expect(table.kind).toBe("table");
|
|
423
|
+
expect(renderedText(table)).toBe("| a | b |\n| --- | --- |\n| 1 | 2 |");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("renderedSpanToWork maps a rendered link label back to its work offsets", () => {
|
|
427
|
+
const display = buildDisplay("Use the `flag` and see [docs](https://x.dev).");
|
|
428
|
+
// "docs" sits at rendered offset 21 ("Use the flag and see ".length)
|
|
429
|
+
const work = renderedSpanToWork(display, 0, 0, 21, 25);
|
|
430
|
+
const workText = display[0]!.work!.text;
|
|
431
|
+
|
|
432
|
+
expect(workText.slice(work.start, work.end)).toBe("docs");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("renderedSpanToWork maps rendered inline code back to the backtick content", () => {
|
|
436
|
+
const display = buildDisplay("Use the `flag` and see docs.");
|
|
437
|
+
// "flag" sits at rendered offset 8 ("Use the ".length)
|
|
438
|
+
const work = renderedSpanToWork(display, 0, 0, 8, 12);
|
|
439
|
+
|
|
440
|
+
expect(display[0]!.work!.text.slice(work.start, work.end)).toBe("flag");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("styledRunsFor colors a link run and marks the covered stretch", () => {
|
|
444
|
+
const linkRow = buildDisplay("see [docs](https://x.dev) now")[0]!;
|
|
445
|
+
const roleRuns = renderedStyleRuns(linkRow);
|
|
446
|
+
// "see docs now"; mark the whole "docs" label (rendered offsets 4..8)
|
|
447
|
+
const runs = styledRunsFor(roleRuns, { start: 0, end: renderedText(linkRow).length }, [
|
|
448
|
+
{ start: 4, end: 8 },
|
|
449
|
+
]);
|
|
450
|
+
const link = runs.find((run) => run.role === "link")!;
|
|
451
|
+
|
|
452
|
+
expect(link.text).toBe("docs");
|
|
453
|
+
expect(link.marked).toBe(true);
|
|
454
|
+
expect(runs.find((run) => run.text === "see ")!.marked).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
});
|