@cueloop/client 0.1.0-alpha.78 → 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.
Files changed (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -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 === "h" || name === "tab") return { ...nav, zone: "nav" };
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: "body",
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
+ }
@@ -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 renderedText = runs.map((run) => run.text).join("");
280
+ const renderedString = runs.map((run) => run.text).join("");
277
281
 
278
- expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
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 renderedText = runs.map((run) => run.text).join("");
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, renderedText.length)!;
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 renderedText = runs.map((run) => run.text).join("");
326
+ const renderedString = runs.map((run) => run.text).join("");
323
327
 
324
- expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
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
+ });
package/src/view-plan.ts CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  type Block,
18
18
  } from "@cueloop/schema";
19
19
  import { wordLevelChanges } from "./diff-intraline";
20
- import { spanRangeInBlock, type TextSpan } from "./thread-selection";
20
+ import { spanRangeInBlock, type CharRange, type TextSpan } from "./thread-selection";
21
+ import type { MarkRange, VisualLine } from "./mark-runs";
21
22
 
22
23
  // ── display model ─────────────────────────────
23
24
 
@@ -37,6 +38,18 @@ export function displayText(block: DisplayBlock): string {
37
38
  return (block.work ?? block.base)!.text;
38
39
  }
39
40
 
41
+ /**
42
+ * The text a block actually paints: every rendered run's text in order, with
43
+ * inline markers concealed. This is the coordinate space the read-only thread
44
+ * surface hit-tests and paints in; it differs from displayText wherever a block
45
+ * carries inline markup (a link, code, or emphasis) whose markers drop away.
46
+ */
47
+ export function renderedText(block: DisplayBlock): string {
48
+ return blockRuns(block, true)
49
+ .map((run) => run.text)
50
+ .join("");
51
+ }
52
+
40
53
  /** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
41
54
  function similar(baseBlock: Block, workBlock: Block): boolean {
42
55
  if (baseBlock.kind !== workBlock.kind) return false;
@@ -214,15 +227,27 @@ export function marksByDisplay(
214
227
  const endEntry = workEntries[resolved.endBlockIndex];
215
228
 
216
229
  if (!startEntry || !endEntry) continue;
230
+ // resolveAnchor works in the block's work text; the surface paints and hit-tests in rendered
231
+ // text (markers concealed), so map the span onto the visible characters it covers
232
+ const renderedStart = renderedOffsetAtOrAfter(
233
+ blockRuns(startEntry.block, true),
234
+ resolved.start,
235
+ );
236
+ const renderedEndInclusive = renderedOffsetAtOrBefore(
237
+ blockRuns(endEntry.block, true),
238
+ Math.max(0, resolved.end - 1),
239
+ );
240
+
241
+ if (renderedStart === null || renderedEndInclusive === null) continue;
217
242
  const span: TextSpan = {
218
- start: { blockIndex: startEntry.displayIndex, char: resolved.start },
219
- end: { blockIndex: endEntry.displayIndex, char: resolved.end },
243
+ start: { blockIndex: startEntry.displayIndex, char: renderedStart },
244
+ end: { blockIndex: endEntry.displayIndex, char: renderedEndInclusive + 1 },
220
245
  };
221
246
 
222
247
  // a span paints on every block it covers; the mark carries the whole span
223
248
  for (let workIndex = resolved.blockIndex; workIndex <= resolved.endBlockIndex; workIndex++) {
224
249
  const entry = workEntries[workIndex]!;
225
- const range = spanRangeInBlock(span, entry.displayIndex, entry.block.work!.text.length);
250
+ const range = spanRangeInBlock(span, entry.displayIndex, renderedText(entry.block).length);
226
251
 
227
252
  if (!range) continue;
228
253
  const marks = marksByIndex.get(entry.displayIndex) ?? [];
@@ -243,7 +268,11 @@ export function marksByDisplay(
243
268
 
244
269
  /** Base runs for a display block: plain text, or word-diff for mod blocks. */
245
270
  export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
246
- const literal = block.kind === "code" || block.kind === "hr";
271
+ const literal =
272
+ block.kind === "code" ||
273
+ block.kind === "hr" ||
274
+ block.kind === "table" ||
275
+ block.kind === "frontmatter";
247
276
 
248
277
  if (block.type === "mod" && markup) {
249
278
  const changes = wordLevelChanges(block.base!.text, block.work!.text);
@@ -315,6 +344,94 @@ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
315
344
  return runs;
316
345
  }
317
346
 
347
+ /** A rendered run positioned by its offset in the block's rendered (concealed) text. */
348
+ export interface RenderedRun {
349
+ text: string;
350
+ role: RunRole;
351
+ /** Offset of this run in the block's rendered text. */
352
+ start: number;
353
+ href?: string;
354
+ }
355
+
356
+ /** The block's role runs positioned in rendered-text coordinates, for painting and hit-testing. */
357
+ export function renderedStyleRuns(block: DisplayBlock): RenderedRun[] {
358
+ const runs: RenderedRun[] = [];
359
+ let rendered = 0;
360
+
361
+ for (const run of blockRuns(block, true)) {
362
+ const positioned: RenderedRun = { text: run.text, role: run.role, start: rendered };
363
+
364
+ if (run.href !== undefined) positioned.href = run.href;
365
+ runs.push(positioned);
366
+ rendered += run.text.length;
367
+ }
368
+
369
+ return runs;
370
+ }
371
+
372
+ /** A painted run of one visual line: its role, its href, and whether a mark or the caret cell covers it. */
373
+ export interface StyledRun {
374
+ text: string;
375
+ role: RunRole;
376
+ marked: boolean;
377
+ caretOnly: boolean;
378
+ href?: string;
379
+ }
380
+
381
+ /**
382
+ * Slice a block's rendered role runs to one visual line and split them further
383
+ * at mark boundaries, so each painted run carries both its markdown role and
384
+ * whether a discussion mark or the caret cell covers it. Every coordinate here
385
+ * is a rendered-text offset; `markRanges` are block-level and clipped to the line.
386
+ */
387
+ export function styledRunsFor(
388
+ roleRuns: RenderedRun[],
389
+ line: VisualLine,
390
+ markRanges: MarkRange[],
391
+ ): StyledRun[] {
392
+ const cuts = new Set<number>([line.start, line.end]);
393
+
394
+ for (const run of roleRuns) {
395
+ if (run.start > line.start && run.start < line.end) cuts.add(run.start);
396
+ const runEnd = run.start + run.text.length;
397
+
398
+ if (runEnd > line.start && runEnd < line.end) cuts.add(runEnd);
399
+ }
400
+ for (const range of markRanges) {
401
+ const start = Math.max(line.start, range.start);
402
+ const end = Math.min(line.end, range.end);
403
+
404
+ if (start < end) {
405
+ cuts.add(start);
406
+ cuts.add(end);
407
+ }
408
+ }
409
+ const edges = [...cuts].toSorted((left, right) => left - right);
410
+ const runs: StyledRun[] = [];
411
+
412
+ for (let index = 0; index < edges.length - 1; index++) {
413
+ const start = edges[index]!;
414
+ const end = edges[index + 1]!;
415
+
416
+ if (end <= start) continue;
417
+ const roleRun = roleRuns.find(
418
+ (candidate) => candidate.start <= start && start < candidate.start + candidate.text.length,
419
+ );
420
+ const covering = markRanges.filter((range) => range.start <= start && end <= range.end);
421
+ const styled: StyledRun = {
422
+ text: roleRun ? roleRun.text.slice(start - roleRun.start, end - roleRun.start) : "",
423
+ role: roleRun?.role ?? "plain",
424
+ marked: covering.some((range) => range.caretOnly !== true),
425
+ caretOnly: covering.some((range) => range.caretOnly === true),
426
+ };
427
+
428
+ if (roleRun?.href !== undefined) styled.href = roleRun.href;
429
+ runs.push(styled);
430
+ }
431
+
432
+ return runs.length > 0 ? runs : [{ text: "", role: "plain", marked: false, caretOnly: false }];
433
+ }
434
+
318
435
  /** Split runs at mark boundaries; marks only bind to runs with offsets. */
319
436
  export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
320
437
  if (!marks.length) return runs;
@@ -455,6 +572,38 @@ export function workRangeForRendered(
455
572
  return range;
456
573
  }
457
574
 
575
+ /**
576
+ * Map a rendered-text selection back to the work-text range an anchor is cut
577
+ * from. The read-only surface yields rendered offsets (markers concealed); an
578
+ * anchor's quote must come from the block's work text, so convert at the seam
579
+ * before makeAnchor. A selection may cross blocks: the start rendered offset
580
+ * lives in `displayIndex`, the end in `endDisplayIndex`.
581
+ */
582
+ export function renderedSpanToWork(
583
+ display: DisplayBlock[],
584
+ displayIndex: number,
585
+ endDisplayIndex: number,
586
+ renderedStart: number,
587
+ renderedEnd: number,
588
+ ): CharRange {
589
+ const startBlock = display[displayIndex]!;
590
+
591
+ if (displayIndex === endDisplayIndex) {
592
+ const range = workRangeForRendered(blockRuns(startBlock, true), renderedStart, renderedEnd);
593
+
594
+ return range ?? { start: renderedStart, end: renderedEnd };
595
+ }
596
+ const startRuns = blockRuns(startBlock, true);
597
+ const startRange = workRangeForRendered(
598
+ startRuns,
599
+ renderedStart,
600
+ renderedText(startBlock).length,
601
+ );
602
+ const endRange = workRangeForRendered(blockRuns(display[endDisplayIndex]!, true), 0, renderedEnd);
603
+
604
+ return { start: startRange?.start ?? renderedStart, end: endRange?.end ?? renderedEnd };
605
+ }
606
+
458
607
  /** Line delta between two revision contents, for the sheet-header summary. */
459
608
  export function revisionDelta(previousContent: string, nextContent: string) {
460
609
  const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
@@ -1,105 +0,0 @@
1
- /** The share shortcut opens an app-level public/private choice reachable from any view. */
2
-
3
- import { afterEach, beforeEach, describe, mock, test } from "bun:test";
4
- import { mkdtempSync, rmSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
- import { DaemonServer } from "@cueloop/daemon";
10
- import type { Thread } from "@cueloop/schema";
11
- import { App } from "./App";
12
- import type { ShareTransport } from "./thread-controller";
13
- import { isolateUserConfig, press, pressKey, waitForText, waitForTextGone } from "./test-support";
14
-
15
- const shareTransport: ShareTransport = {
16
- publish: mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true })),
17
- pull: mock(async () => {
18
- throw new Error("Unexpected share pull");
19
- }),
20
- push: mock(async () => {}),
21
- watch: () => () => {},
22
- parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
23
- collaboratorAnnotations: () => [],
24
- mergeFromShare: () => ({ annotations: [] }),
25
- };
26
-
27
- const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
28
-
29
- let home: string;
30
- let restoreUserConfig: () => void;
31
- let server: DaemonServer;
32
- let session: Thread;
33
-
34
- beforeEach(() => {
35
- home = mkdtempSync(join(tmpdir(), "cueloop-share-choice-"));
36
- restoreUserConfig = isolateUserConfig(home);
37
- server = new DaemonServer({ home, idleExitMs: 0 });
38
- server.start();
39
- session = server.core.sessionCreate({
40
- workspace: { repoRoot: "/repo", branch: "main" },
41
- artifact: {
42
- type: "plan",
43
- content: PLAN,
44
- meta: { title: "Migration Plan", planPath: "plan.md" },
45
- },
46
- });
47
- });
48
- afterEach(() => {
49
- restoreUserConfig();
50
- server.stop();
51
- rmSync(home, { recursive: true, force: true });
52
- });
53
-
54
- describe("share choice", () => {
55
- test("the shortcut opens the public/private choice", async () => {
56
- // Arrange
57
- const setup = await testRender(
58
- <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
59
- { width: 120, height: 32 },
60
- );
61
- await waitForText(setup, "cueloop");
62
-
63
- // Act
64
- await pressKey(setup, "s", { ctrl: true });
65
-
66
- // Assert
67
- await waitForText(setup, "public link");
68
- await waitForText(setup, "private link");
69
- });
70
-
71
- test("the private choice opens the manage-access surface", async () => {
72
- // Arrange
73
- const setup = await testRender(
74
- <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
75
- { width: 120, height: 32 },
76
- );
77
- await waitForText(setup, "cueloop");
78
-
79
- // Act: open the choice, move to private, select it
80
- await pressKey(setup, "s", { ctrl: true });
81
- await waitForText(setup, "private link");
82
- await press(setup, "down");
83
- await press(setup, "enter");
84
-
85
- // Assert
86
- await waitForText(setup, "Manage access");
87
- });
88
-
89
- test("escape cancels the choice without sharing", async () => {
90
- // Arrange
91
- const setup = await testRender(
92
- <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
93
- { width: 120, height: 32 },
94
- );
95
- await waitForText(setup, "cueloop");
96
-
97
- // Act
98
- await pressKey(setup, "s", { ctrl: true });
99
- await waitForText(setup, "public link");
100
- await press(setup, "escape");
101
-
102
- // Assert
103
- await waitForTextGone(setup, "public link");
104
- });
105
- });
@@ -1,36 +0,0 @@
1
- import React from "react";
2
- import { DARK } from "../theme";
3
- import type { Story, StoryMeta } from "./story";
4
- import { ManageAccessDialog } from "./ManageAccessDialog";
5
-
6
- export const meta: StoryMeta = { title: "Dialogs/ManageAccess" };
7
-
8
- export const WithHandles: Story = {
9
- render: () => (
10
- <ManageAccessDialog
11
- isOpen
12
- logins={["octocat", "hubot"]}
13
- onAdd={() => {}}
14
- onRemove={() => {}}
15
- onCreateLink={() => {}}
16
- onClose={() => {}}
17
- theme={DARK}
18
- />
19
- ),
20
- size: { width: 60, height: 18 },
21
- };
22
-
23
- export const Empty: Story = {
24
- render: () => (
25
- <ManageAccessDialog
26
- isOpen
27
- logins={[]}
28
- onAdd={() => {}}
29
- onRemove={() => {}}
30
- onCreateLink={() => {}}
31
- onClose={() => {}}
32
- theme={DARK}
33
- />
34
- ),
35
- size: { width: 60, height: 18 },
36
- };
@@ -1,94 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import React, { useState } from "react";
3
- import { testRender } from "@opentui/react/test-utils";
4
- import { DARK } from "../theme";
5
- import { clickText, locateTextInFrame, settle, typeText } from "../test-support";
6
- import { ManageAccessDialog } from "./ManageAccessDialog";
7
-
8
- function AccessHarness({ initial }: { initial: string[] }): React.ReactNode {
9
- const [logins, setLogins] = useState<string[]>(initial);
10
- const [open, setOpen] = useState(true);
11
-
12
- return (
13
- <box style={{ width: 60, height: 18 }}>
14
- <ManageAccessDialog
15
- isOpen={open}
16
- logins={logins}
17
- onAdd={(login) => setLogins((prev) => [...prev, login])}
18
- onRemove={(login) => setLogins((prev) => prev.filter((entry) => entry !== login))}
19
- onCreateLink={() => {}}
20
- onClose={() => setOpen(false)}
21
- theme={DARK}
22
- />
23
- </box>
24
- );
25
- }
26
-
27
- async function renderAccess(initial: string[]): Promise<Awaited<ReturnType<typeof testRender>>> {
28
- const setup = await testRender(<AccessHarness initial={initial} />, { width: 60, height: 18 });
29
-
30
- await settle(setup);
31
-
32
- return setup;
33
- }
34
-
35
- describe("ManageAccessDialog", () => {
36
- test("renders the allowlist of GitHub handles", async () => {
37
- const setup = await renderAccess(["octocat", "hubot"]);
38
- const frame = setup.captureCharFrame();
39
-
40
- expect(locateTextInFrame(frame, "@octocat")).not.toBeNull();
41
- expect(locateTextInFrame(frame, "@hubot")).not.toBeNull();
42
- });
43
-
44
- test("clicking a row removes that handle from the allowlist", async () => {
45
- const setup = await renderAccess(["octocat", "hubot"]);
46
-
47
- await clickText(setup, "@octocat");
48
-
49
- expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).toBeNull();
50
- expect(locateTextInFrame(setup.captureCharFrame(), "@hubot")).not.toBeNull();
51
- });
52
-
53
- test("typing a handle and pressing add appends it to the allowlist", async () => {
54
- const setup = await renderAccess([]);
55
-
56
- await typeText(setup, "octocat");
57
- await clickText(setup, "add");
58
-
59
- expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).not.toBeNull();
60
- });
61
-
62
- test("clicking create link publishes the private link", async () => {
63
- let created = false;
64
- const setup = await testRender(
65
- <box style={{ width: 60, height: 18 }}>
66
- <ManageAccessDialog
67
- isOpen
68
- logins={["octocat"]}
69
- onAdd={() => {}}
70
- onRemove={() => {}}
71
- onCreateLink={() => {
72
- created = true;
73
- }}
74
- onClose={() => {}}
75
- theme={DARK}
76
- />
77
- </box>,
78
- { width: 60, height: 18 },
79
- );
80
-
81
- await settle(setup);
82
- await clickText(setup, "create link");
83
-
84
- expect(created).toBe(true);
85
- });
86
-
87
- test("clicking close dismisses the dialog", async () => {
88
- const setup = await renderAccess([]);
89
-
90
- await clickText(setup, "close");
91
-
92
- expect(locateTextInFrame(setup.captureCharFrame(), "Manage access")).toBeNull();
93
- });
94
- });