@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +14 -2
- package/src/App.export.test.tsx +1 -2
- package/src/App.plan-review.test.tsx +16 -0
- package/src/App.submit-confirm.test.tsx +34 -5
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +4 -0
- package/src/App.walk.test.tsx +2 -2
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +7 -3
- package/src/app-view-model.ts +3 -0
- 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.tsx +11 -12
- package/src/components/DiffContentView.tsx +4 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/ManageAccessDialog.tsx +13 -11
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/ShareChoiceDialog.tsx +0 -2
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadTree.tsx +11 -6
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -1
- package/src/components/ThreadView.tsx +66 -7
- package/src/components/Toast.tsx +0 -5
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
- package/src/components/primitives/Card.tsx +3 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +8 -1
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -1
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/run.ts +31 -19
- package/src/serve.ts +1 -1
- package/src/share.ts +1 -1
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.switch-cache.test.ts +146 -0
- package/src/thread-controller.ts +157 -24
- package/src/use-annotation-surface.tsx +43 -27
package/src/thread-controller.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The thread controller: every daemon round-trip and mutation
|
|
3
3
|
* primitive behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
4
|
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
5
|
* primitives (cut/edit/annotate/submit/...), and the post-submit completion
|
|
@@ -132,6 +132,41 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
|
132
132
|
return `plan:${base.lineStart}-${base.lineEnd}`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether two records of the same thread yield the same document projection (display, rows, files,
|
|
137
|
+
* models). Those derive only from the artifact content and the working copy, so an update that
|
|
138
|
+
* touched only annotations, status, or the verdict leaves them identical - the projection can be
|
|
139
|
+
* reused rather than re-parsed. Content and working copy are strings, compared here by value.
|
|
140
|
+
*/
|
|
141
|
+
export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
|
|
142
|
+
return (
|
|
143
|
+
previous.id === next.id &&
|
|
144
|
+
previous.artifact.content === next.artifact.content &&
|
|
145
|
+
previous.workingCopy === next.workingCopy
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The session with `annotation` upserted, for an optimistic paint the instant the composer closes
|
|
151
|
+
* rather than after the daemon round-trip - otherwise a saved comment blinks out until the write
|
|
152
|
+
* lands. An edit keeps the note in place and its timestamp; a new note appends. The daemon's record
|
|
153
|
+
* replaces this guess when the write returns.
|
|
154
|
+
*/
|
|
155
|
+
export function withAnnotationUpserted(
|
|
156
|
+
session: Thread,
|
|
157
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
158
|
+
): Thread {
|
|
159
|
+
const index = session.annotations.findIndex((entry) => entry.id === annotation.id);
|
|
160
|
+
const createdAt = index >= 0 ? session.annotations[index]!.createdAt : new Date().toISOString();
|
|
161
|
+
const optimistic: Annotation = { ...annotation, createdAt };
|
|
162
|
+
const annotations =
|
|
163
|
+
index >= 0
|
|
164
|
+
? session.annotations.map((entry) => (entry.id === annotation.id ? optimistic : entry))
|
|
165
|
+
: [...session.annotations, optimistic];
|
|
166
|
+
|
|
167
|
+
return { ...session, annotations };
|
|
168
|
+
}
|
|
169
|
+
|
|
135
170
|
export interface ControllerSnapshot {
|
|
136
171
|
session: Thread | null;
|
|
137
172
|
inbox: Thread[] | null;
|
|
@@ -345,6 +380,9 @@ interface DerivedSessionProjection {
|
|
|
345
380
|
display: DisplayBlock[];
|
|
346
381
|
rows: DiffRow[];
|
|
347
382
|
files: WalkFile[];
|
|
383
|
+
/** Raw per-file contents; parsed into a model lazily on first curation touch, not all upfront. */
|
|
384
|
+
fileContents: Map<string, DiffFileContents>;
|
|
385
|
+
/** Parsed file models, filled on demand from fileContents and memoized here. */
|
|
348
386
|
models: Map<string, FileDiffMetadata>;
|
|
349
387
|
tree: TreeRow[];
|
|
350
388
|
}
|
|
@@ -355,6 +393,15 @@ interface LiveWorkingDiff {
|
|
|
355
393
|
files: readonly DiffFileContents[];
|
|
356
394
|
}
|
|
357
395
|
|
|
396
|
+
interface DerivedCacheEntry {
|
|
397
|
+
derivedFor: Thread;
|
|
398
|
+
derivedForLiveDiff: LiveWorkingDiff | null;
|
|
399
|
+
derived: DerivedSessionProjection;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Recently-viewed threads whose parsed projection is kept for an instant return; oldest evicted first. */
|
|
403
|
+
const DERIVED_CACHE_LIMIT = 8;
|
|
404
|
+
|
|
358
405
|
class Controller implements ReviewController {
|
|
359
406
|
readonly readOnly: boolean;
|
|
360
407
|
private client: SessionClient | null = null;
|
|
@@ -389,9 +436,12 @@ class Controller implements ReviewController {
|
|
|
389
436
|
display: [],
|
|
390
437
|
rows: [],
|
|
391
438
|
files: [],
|
|
439
|
+
fileContents: new Map(),
|
|
392
440
|
models: new Map(),
|
|
393
441
|
tree: [],
|
|
394
442
|
};
|
|
443
|
+
/** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
|
|
444
|
+
private derivedCache = new Map<string, DerivedCacheEntry>();
|
|
395
445
|
/** File paths whose diff body is folded to just the file band. */
|
|
396
446
|
private collapsedFiles = new Set<string>();
|
|
397
447
|
/** File paths woven to their full contents (unchanged lines shown as context). */
|
|
@@ -498,6 +548,57 @@ class Controller implements ReviewController {
|
|
|
498
548
|
}
|
|
499
549
|
|
|
500
550
|
// ── derived projections ─────────────────────
|
|
551
|
+
/**
|
|
552
|
+
* The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
|
|
553
|
+
* files, and models derive only from the artifact and working copy - never from annotations,
|
|
554
|
+
* status, or the verdict - so an update that touched only those produces an all-new session
|
|
555
|
+
* record but an identical projection.
|
|
556
|
+
*/
|
|
557
|
+
private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
|
|
558
|
+
return (
|
|
559
|
+
this.derivedFor !== null &&
|
|
560
|
+
this.derivedForLiveDiff === liveDiff &&
|
|
561
|
+
sameDerivationInputs(this.derivedFor, session)
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private buildDerived(
|
|
566
|
+
session: Thread | null,
|
|
567
|
+
frozenDiff: boolean,
|
|
568
|
+
liveDiff: LiveWorkingDiff | null,
|
|
569
|
+
): DerivedSessionProjection {
|
|
570
|
+
const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
|
|
571
|
+
const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
|
|
572
|
+
const rows = content ? diffRows(content) : [];
|
|
573
|
+
const fileContents = new Map<string, DiffFileContents>();
|
|
574
|
+
|
|
575
|
+
for (const file of files) fileContents.set(file.path, file);
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
579
|
+
rows,
|
|
580
|
+
files: walkFiles(rows),
|
|
581
|
+
fileContents,
|
|
582
|
+
models: new Map<string, FileDiffMetadata>(),
|
|
583
|
+
tree: session?.history ? treeRows(session.history) : [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The parsed model for a changed file, computed on first touch and memoized in the projection. */
|
|
588
|
+
private fileModel(path: string): FileDiffMetadata | undefined {
|
|
589
|
+
const cached = this.derived.models.get(path);
|
|
590
|
+
|
|
591
|
+
if (cached) return cached;
|
|
592
|
+
const contents = this.derived.fileContents.get(path);
|
|
593
|
+
|
|
594
|
+
if (!contents) return undefined;
|
|
595
|
+
const model = parseFileDiff(contents);
|
|
596
|
+
|
|
597
|
+
this.derived.models.set(path, model);
|
|
598
|
+
|
|
599
|
+
return model;
|
|
600
|
+
}
|
|
601
|
+
|
|
501
602
|
private ensureDerived(): void {
|
|
502
603
|
const session = this.snapshot.session;
|
|
503
604
|
const frozenDiff = readsFrozenDiff(session);
|
|
@@ -505,27 +606,60 @@ class Controller implements ReviewController {
|
|
|
505
606
|
const liveDiff = frozenDiff ? null : this.liveDiff;
|
|
506
607
|
|
|
507
608
|
if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
|
|
508
|
-
//
|
|
509
|
-
if (
|
|
609
|
+
// reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
|
|
610
|
+
if (session !== null && this.reusesDerived(session, liveDiff)) {
|
|
611
|
+
this.derivedFor = session;
|
|
612
|
+
this.derived = { ...this.derived, tree: session.history ? treeRows(session.history) : [] };
|
|
613
|
+
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
|
|
617
|
+
if (this.derivedFor?.id !== session?.id) {
|
|
510
618
|
this.collapsedFiles.clear();
|
|
511
619
|
this.expandedFiles.clear();
|
|
512
620
|
}
|
|
621
|
+
// returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
|
|
622
|
+
const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
|
|
623
|
+
|
|
624
|
+
if (
|
|
625
|
+
cached &&
|
|
626
|
+
cached.derivedForLiveDiff === liveDiff &&
|
|
627
|
+
sameDerivationInputs(cached.derivedFor, session!)
|
|
628
|
+
) {
|
|
629
|
+
this.derivedFor = session;
|
|
630
|
+
this.derivedForLiveDiff = liveDiff;
|
|
631
|
+
this.derived = {
|
|
632
|
+
...cached.derived,
|
|
633
|
+
tree: session!.history ? treeRows(session!.history) : [],
|
|
634
|
+
};
|
|
635
|
+
this.rememberDerived(session!, liveDiff, this.derived);
|
|
636
|
+
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
513
639
|
this.derivedFor = session;
|
|
514
640
|
this.derivedForLiveDiff = liveDiff;
|
|
641
|
+
this.derived = this.buildDerived(session, frozenDiff, liveDiff);
|
|
642
|
+
if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
|
|
643
|
+
}
|
|
515
644
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
645
|
+
/** Keep the freshest projections per thread, evicting the least-recently-used past the cap. */
|
|
646
|
+
private rememberDerived(
|
|
647
|
+
session: Thread,
|
|
648
|
+
liveDiff: LiveWorkingDiff | null,
|
|
649
|
+
derived: DerivedSessionProjection,
|
|
650
|
+
): void {
|
|
651
|
+
this.derivedCache.delete(session.id);
|
|
652
|
+
this.derivedCache.set(session.id, {
|
|
653
|
+
derivedFor: session,
|
|
654
|
+
derivedForLiveDiff: liveDiff,
|
|
655
|
+
derived,
|
|
656
|
+
});
|
|
657
|
+
while (this.derivedCache.size > DERIVED_CACHE_LIMIT) {
|
|
658
|
+
const oldest = this.derivedCache.keys().next().value;
|
|
520
659
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
rows,
|
|
525
|
-
files: walkFiles(rows),
|
|
526
|
-
models,
|
|
527
|
-
tree: session?.history ? treeRows(session.history) : [],
|
|
528
|
-
};
|
|
660
|
+
if (oldest === undefined) break;
|
|
661
|
+
this.derivedCache.delete(oldest);
|
|
662
|
+
}
|
|
529
663
|
}
|
|
530
664
|
|
|
531
665
|
/** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
|
|
@@ -821,7 +955,7 @@ class Controller implements ReviewController {
|
|
|
821
955
|
|
|
822
956
|
return null;
|
|
823
957
|
}
|
|
824
|
-
const model = this.
|
|
958
|
+
const model = this.fileModel(row.file);
|
|
825
959
|
|
|
826
960
|
if (!model) return null;
|
|
827
961
|
|
|
@@ -902,7 +1036,7 @@ class Controller implements ReviewController {
|
|
|
902
1036
|
const rejected = new Set<number>();
|
|
903
1037
|
|
|
904
1038
|
this.derived.rows.forEach((row, index) => {
|
|
905
|
-
const model = this.
|
|
1039
|
+
const model = this.fileModel(row.file);
|
|
906
1040
|
|
|
907
1041
|
if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
|
|
908
1042
|
});
|
|
@@ -979,7 +1113,7 @@ class Controller implements ReviewController {
|
|
|
979
1113
|
|
|
980
1114
|
/** The change/deletion rows a rejection covers, for its preview and reveal row. */
|
|
981
1115
|
private rowsForRejection(rejection: HunkRejection): DiffRow[] {
|
|
982
|
-
const model = this.
|
|
1116
|
+
const model = this.fileModel(rejection.path);
|
|
983
1117
|
|
|
984
1118
|
if (!model) return [];
|
|
985
1119
|
const rows: DiffRow[] = [];
|
|
@@ -1094,7 +1228,7 @@ class Controller implements ReviewController {
|
|
|
1094
1228
|
: { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
|
|
1095
1229
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1096
1230
|
|
|
1097
|
-
this.
|
|
1231
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1098
1232
|
this.mirrorAnnotation(persisted, wire);
|
|
1099
1233
|
|
|
1100
1234
|
return wire.id;
|
|
@@ -1110,7 +1244,7 @@ class Controller implements ReviewController {
|
|
|
1110
1244
|
: { id: newAnnotationId(), kind: "comment", anchor, body, target };
|
|
1111
1245
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1112
1246
|
|
|
1113
|
-
this.
|
|
1247
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1114
1248
|
this.mirrorAnnotation(persisted, wire);
|
|
1115
1249
|
|
|
1116
1250
|
return wire.id;
|
|
@@ -1176,7 +1310,7 @@ class Controller implements ReviewController {
|
|
|
1176
1310
|
const wire = root.target ? { ...base, target: root.target } : base;
|
|
1177
1311
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1178
1312
|
|
|
1179
|
-
this.
|
|
1313
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1180
1314
|
this.mirrorAnnotation(persisted, wire);
|
|
1181
1315
|
|
|
1182
1316
|
return wire.id;
|
|
@@ -1190,7 +1324,7 @@ class Controller implements ReviewController {
|
|
|
1190
1324
|
const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
|
|
1191
1325
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1192
1326
|
|
|
1193
|
-
this.
|
|
1327
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1194
1328
|
this.mirrorAnnotation(persisted, wire);
|
|
1195
1329
|
|
|
1196
1330
|
return wire.id;
|
|
@@ -1215,9 +1349,8 @@ class Controller implements ReviewController {
|
|
|
1215
1349
|
if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
|
|
1216
1350
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1217
1351
|
|
|
1218
|
-
this.
|
|
1352
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1219
1353
|
this.mirrorAnnotation(persisted, wire);
|
|
1220
|
-
this.setStatus("annotation updated");
|
|
1221
1354
|
}
|
|
1222
1355
|
|
|
1223
1356
|
// push only after the local write lands, so a rejected write never leaks to the share
|
|
@@ -40,6 +40,7 @@ import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs"
|
|
|
40
40
|
import { matchesLeader } from "./thread-chords";
|
|
41
41
|
import {
|
|
42
42
|
activeSlashToken,
|
|
43
|
+
insertSlashItem,
|
|
43
44
|
isStandaloneSlashQuery,
|
|
44
45
|
mergeSlashItems,
|
|
45
46
|
slashFilter,
|
|
@@ -243,6 +244,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
243
244
|
};
|
|
244
245
|
const [folded, setFolded] = useState<Set<string>>(new Set());
|
|
245
246
|
const [composeText, setComposeText] = useState("");
|
|
247
|
+
const composeTextRef = useRef("");
|
|
248
|
+
const setDraft = (text: string): void => {
|
|
249
|
+
composeTextRef.current = text;
|
|
250
|
+
setComposeText(text);
|
|
251
|
+
};
|
|
246
252
|
const [caretOffset, setCaretOffset] = useState(0);
|
|
247
253
|
const [slashIndex, setSlashIndex] = useState(0);
|
|
248
254
|
const composerReady = useRef(false);
|
|
@@ -314,14 +320,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
314
320
|
composerReady.current = false;
|
|
315
321
|
composeRef.current = state;
|
|
316
322
|
setCompose(state);
|
|
317
|
-
|
|
323
|
+
setDraft(state.seed);
|
|
318
324
|
setCaretOffset(state.seed.length);
|
|
319
325
|
setSlashIndex(0);
|
|
320
326
|
};
|
|
321
327
|
const closeCompose = (): void => {
|
|
322
328
|
composeRef.current = null;
|
|
323
329
|
setCompose(null);
|
|
324
|
-
|
|
330
|
+
setDraft("");
|
|
325
331
|
setCaretOffset(0);
|
|
326
332
|
};
|
|
327
333
|
const skills = useContext(SlashSkillsContext);
|
|
@@ -451,14 +457,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
451
457
|
return true;
|
|
452
458
|
}
|
|
453
459
|
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);
|
|
460
|
+
const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
|
|
458
461
|
|
|
459
|
-
openCompose({ ...activeCompose, seed });
|
|
460
|
-
|
|
461
|
-
setCaretOffset(cut + insertion.length);
|
|
462
|
+
openCompose({ ...activeCompose, seed: inserted.text });
|
|
463
|
+
setCaretOffset(inserted.caret);
|
|
462
464
|
|
|
463
465
|
return true;
|
|
464
466
|
}
|
|
@@ -473,7 +475,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
473
475
|
const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
|
|
474
476
|
|
|
475
477
|
composeRef.current = grown;
|
|
476
|
-
|
|
478
|
+
setDraft(grown.seed);
|
|
477
479
|
setCaretOffset(grown.seed.length);
|
|
478
480
|
|
|
479
481
|
return setCompose(grown);
|
|
@@ -484,7 +486,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
484
486
|
const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
|
|
485
487
|
|
|
486
488
|
composeRef.current = grown;
|
|
487
|
-
|
|
489
|
+
setDraft(grown.seed);
|
|
488
490
|
setCaretOffset(grown.seed.length);
|
|
489
491
|
setCompose(grown);
|
|
490
492
|
}
|
|
@@ -503,7 +505,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
503
505
|
// (an edit of an existing comment is never deleted this way)
|
|
504
506
|
if (
|
|
505
507
|
key.name === "backspace" &&
|
|
506
|
-
|
|
508
|
+
composeTextRef.current.length === 0 &&
|
|
507
509
|
activeCompose.editAnnotationId === null
|
|
508
510
|
) {
|
|
509
511
|
return closeCompose();
|
|
@@ -704,7 +706,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
704
706
|
onSave={saveComment}
|
|
705
707
|
onReady={() => (composerReady.current = true)}
|
|
706
708
|
onInput={(text, caret) => {
|
|
707
|
-
|
|
709
|
+
setDraft(text);
|
|
708
710
|
setCaretOffset(caret);
|
|
709
711
|
}}
|
|
710
712
|
/>
|
|
@@ -799,9 +801,36 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
799
801
|
const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
|
|
800
802
|
const nodes: React.ReactNode[] = [];
|
|
801
803
|
const composeHere = compose && compose.blockIndex === blockIndex;
|
|
804
|
+
const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
|
|
805
|
+
const composeAnchoredHere =
|
|
806
|
+
newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
|
|
807
|
+
const composeStart = compose?.span?.start;
|
|
808
|
+
|
|
809
|
+
const pushComposeCard = (): void => {
|
|
810
|
+
nodes.push(
|
|
811
|
+
<DiscussionCard
|
|
812
|
+
key="compose-new"
|
|
813
|
+
tokens={tokens}
|
|
814
|
+
segments={[{ color: palette.cardEdge, node: composerNode }]}
|
|
815
|
+
/>,
|
|
816
|
+
);
|
|
817
|
+
nodes.push(paletteNode);
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// the draft sorts in by its span start, the same order discussionsFrom lands it on save, so a
|
|
821
|
+
// new comment sits in its final position while typing instead of appending to the stack
|
|
822
|
+
let composePending = composeAnchoredHere;
|
|
802
823
|
|
|
803
824
|
for (const discussion of discussions) {
|
|
804
825
|
if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
|
|
826
|
+
if (
|
|
827
|
+
composePending &&
|
|
828
|
+
composeStart &&
|
|
829
|
+
comparePositions(composeStart, discussion.span.start) < 0
|
|
830
|
+
) {
|
|
831
|
+
pushComposeCard();
|
|
832
|
+
composePending = false;
|
|
833
|
+
}
|
|
805
834
|
const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
|
|
806
835
|
|
|
807
836
|
if (folded.has(discussion.key) && !composingHere) {
|
|
@@ -811,20 +840,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
811
840
|
nodes.push(discussionCardFor(discussion, composingHere));
|
|
812
841
|
if (composingHere) nodes.push(paletteNode);
|
|
813
842
|
}
|
|
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
|
-
}
|
|
843
|
+
if (composePending) pushComposeCard();
|
|
828
844
|
|
|
829
845
|
return nodes;
|
|
830
846
|
};
|