@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
@@ -16,16 +16,28 @@ import React, { useEffect, useRef } from "react";
16
16
  import { useTerminalDimensions } from "@opentui/react";
17
17
  import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
18
18
  import type { Annotation, Thread } from "@cueloop/schema";
19
- import { displayText, type DisplayBlock, type Mark } from "../view-plan";
19
+ import {
20
+ displayText,
21
+ renderedText,
22
+ renderedStyleRuns,
23
+ styledRunsFor,
24
+ type DisplayBlock,
25
+ type Mark,
26
+ type RenderedRun,
27
+ type RunRole,
28
+ type StyledRun,
29
+ } from "../view-plan";
20
30
  import type { TextSpan } from "../thread-selection";
21
31
  import type { QuickAction } from "../config";
22
32
  import type { CheatsheetSection } from "../key-bindings";
23
33
  import type { Theme } from "../theme";
24
- import { BOLD, CUT, UNDERLINE } from "../annotation-palette";
25
- import { lineMarkRanges, runsFor, wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
34
+ import { BOLD, CUT, ITALIC, UNDERLINE } from "../annotation-palette";
35
+ import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
36
+ import { MarkdownGridBlock } from "./MarkdownGridBlock";
26
37
  import { useFrameMeasure } from "../use-frame-measure";
27
38
  import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
28
39
  import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
40
+ import { NavModeHint } from "./NavModeHint";
29
41
  import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
30
42
  import { useComponentTheme } from "./theme-context";
31
43
 
@@ -35,15 +47,14 @@ const OVERSCAN_BLOCKS = 8;
35
47
  export { lighten } from "../annotation-palette";
36
48
 
37
49
  /**
38
- * How a block's rows are painted: heading weight, muted kinds, the list or
39
- * quote marker, and tracked changes as the plan sheet drew them - a cut block
40
- * dim and struck through, an added or edited block tagged on its first row.
50
+ * How a block's rows are painted: heading weight, muted kinds, and the list or
51
+ * quote marker. A cut block reads dim and struck through; other edits are
52
+ * tracked into the working copy but not tagged in the read-only view.
41
53
  */
42
54
  interface BlockStyle {
43
55
  baseFg: string;
44
56
  baseAttributes: number;
45
57
  marker: string;
46
- changeTag: { text: string; fg: string } | null;
47
58
  }
48
59
 
49
60
  function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
@@ -58,27 +69,42 @@ function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
58
69
  : block.kind === "quote"
59
70
  ? "▏ "
60
71
  : "";
61
- const changeTag =
62
- block.type === "add"
63
- ? { text: " [new]", fg: tokens.green }
64
- : block.type === "mod"
65
- ? { text: " [edited]", fg: tokens.accent }
66
- : null;
67
72
 
68
73
  return {
69
74
  baseFg: isCut ? tokens.textDim : muted ? tokens.textMuted : tokens.text,
70
75
  baseAttributes: (isHeading ? BOLD : 0) | (isCut ? CUT : 0),
71
76
  marker,
72
- changeTag,
73
77
  };
74
78
  }
75
79
 
80
+ /** The read-only color of an inline markdown role: links accent, code and removals dim, insertions green. */
81
+ function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
82
+ if (role === "link") return tokens.blue;
83
+ if (role === "ins") return tokens.green;
84
+ if (role === "code" || role === "del") return tokens.textDim;
85
+
86
+ return baseFg;
87
+ }
88
+
89
+ /** The text attributes an inline role adds over a block's base attributes. */
90
+ function roleAttributes(role: RunRole, baseAttributes: number): number {
91
+ if (role === "strong") return baseAttributes | BOLD;
92
+ if (role === "em") return baseAttributes | ITALIC;
93
+ if (role === "strike" || role === "del") return baseAttributes | CUT;
94
+
95
+ return baseAttributes;
96
+ }
97
+
98
+ function isGridKind(kind: DisplayBlock["kind"]): boolean {
99
+ return kind === "table" || kind === "frontmatter";
100
+ }
101
+
76
102
  /* ------------------------------------------------------------------ view */
77
103
 
78
104
  /** The grammar as the keybinds dialog lists it; the view owns these keys, so they are not rebindable. */
79
105
  export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
80
106
  {
81
- title: "Thread",
107
+ title: "Thread (type)",
82
108
  entries: [
83
109
  { keys: "click", label: "place the caret" },
84
110
  { keys: "drag", label: "mark text, across blocks" },
@@ -87,11 +113,9 @@ export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
87
113
  { keys: "⇧← / ⇧→", label: "hold a mark" },
88
114
  { keys: "↑ / ↓", label: "move by block" },
89
115
  { keys: "type", label: "comment on the mark" },
90
- { keys: "⌘⌥m", label: "comment" },
91
116
  { keys: "enter", label: "reply to the comment" },
92
- { keys: "tab", label: "fold / unfold" },
93
- { keys: "⌘] / ⌘[", label: "next / previous comment" },
94
- { keys: "esc", label: "drop the mark" },
117
+ { keys: "esc", label: "nav mode" },
118
+ { keys: "tab", label: "cycle panes" },
95
119
  { keys: "⌃q", label: "quit" },
96
120
  ],
97
121
  },
@@ -134,8 +158,7 @@ export interface ThreadViewProps {
134
158
  onUpdateAnnotation: (id: string, body: string) => void;
135
159
  /** The author's display name for a comment's hover tooltip. */
136
160
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
137
- leaderCombos?: readonly string[];
138
- onLeaderCommand?: (key: KeyEvent) => void;
161
+ onNavCommand?: (key: KeyEvent) => boolean;
139
162
  onExit: () => void;
140
163
  theme?: Theme;
141
164
  }
@@ -158,15 +181,15 @@ export function ThreadView({
158
181
  onReply,
159
182
  onUpdateAnnotation,
160
183
  resolveAuthorLabel,
161
- leaderCombos,
162
- onLeaderCommand,
184
+ onNavCommand,
163
185
  onExit,
164
186
  theme,
165
187
  }: ThreadViewProps): React.ReactNode {
166
188
  const tokens = useComponentTheme(theme);
167
189
  const source: LineSource = {
168
190
  count: display.length,
169
- textAt: (blockIndex) => displayText(display[blockIndex]!),
191
+ // the surface hit-tests and paints in rendered text (inline markers concealed); a grid anchors on its raw source
192
+ textAt: (blockIndex) => renderedText(display[blockIndex]!),
170
193
  annotatable: () => true,
171
194
  };
172
195
  const surface = useAnnotationSurface({
@@ -187,8 +210,7 @@ export function ThreadView({
187
210
  onReply,
188
211
  onUpdateAnnotation,
189
212
  resolveAuthorLabel,
190
- leaderCombos,
191
- onLeaderCommand,
213
+ onNavCommand,
192
214
  onExit,
193
215
  });
194
216
  const { palette, discussions } = surface;
@@ -227,21 +249,34 @@ export function ThreadView({
227
249
 
228
250
  interface LineContext {
229
251
  blockIndex: number;
230
- text: string;
252
+ roleRuns: RenderedRun[];
231
253
  line: VisualLine;
232
254
  lineIndex: number;
233
255
  ranges: MarkRange[];
234
256
  marker: string;
235
257
  baseFg: string;
236
258
  baseAttributes: number;
237
- /** A tag after the row's text, such as the tracked-change label. */
238
- trailing: { text: string; fg: string } | null;
239
259
  }
240
260
 
261
+ const paintedSpan = (
262
+ run: StyledRun,
263
+ runIndex: number,
264
+ baseFg: string,
265
+ baseAttributes: number,
266
+ ) => (
267
+ <span
268
+ key={runIndex}
269
+ fg={roleForeground(run.role, baseFg, tokens)}
270
+ bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
271
+ attributes={roleAttributes(run.role, baseAttributes) | (run.marked ? UNDERLINE : 0)}
272
+ >
273
+ {run.text}
274
+ </span>
275
+ );
276
+
241
277
  const lineRowFor = (context: LineContext): React.ReactNode => {
242
- const { blockIndex, text, line, lineIndex, ranges, marker, baseFg, baseAttributes, trailing } =
278
+ const { blockIndex, roleRuns, line, lineIndex, ranges, marker, baseFg, baseAttributes } =
243
279
  context;
244
- const lineRanges = lineMarkRanges(ranges, line);
245
280
 
246
281
  return (
247
282
  <box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
@@ -255,17 +290,25 @@ export function ThreadView({
255
290
  ref={surface.registerLine(blockIndex, lineIndex, line)}
256
291
  onMouseDown={surface.onLineMouseDown}
257
292
  >
258
- {runsFor(text.slice(line.start, line.end), lineRanges).map((run, runIndex) => (
259
- <span
260
- key={runIndex}
261
- fg={baseFg}
262
- bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
263
- attributes={baseAttributes | (run.marked ? UNDERLINE : 0)}
264
- >
265
- {run.text}
266
- </span>
267
- ))}
268
- {trailing ? <span fg={trailing.fg}>{trailing.text}</span> : null}
293
+ {styledRunsFor(roleRuns, line, ranges).map((run, runIndex) =>
294
+ paintedSpan(run, runIndex, baseFg, baseAttributes),
295
+ )}
296
+ </text>
297
+ </box>
298
+ );
299
+ };
300
+
301
+ // an h1 or h2 sits over a dim rule; h3 gets weight only. The row is
302
+ // always present for those kinds (only its width tracks the measured viewport) so a late width
303
+ // measurement never adds a row and shifts the blocks below it out from under a pending click
304
+ const headingRule = (block: DisplayBlock): React.ReactNode => {
305
+ if (block.kind !== "h1" && block.kind !== "h2") return null;
306
+ const width = Math.max(1, (viewWidth > 6 ? viewWidth : 46) - 6);
307
+
308
+ return (
309
+ <box key="heading-rule" style={{ flexDirection: "row" }}>
310
+ <text selectable={false} fg={tokens.textDim}>
311
+ {" " + "─".repeat(width)}
269
312
  </text>
270
313
  </box>
271
314
  );
@@ -273,10 +316,54 @@ export function ThreadView({
273
316
 
274
317
  const blockNodeFor = (blockIndex: number): React.ReactNode => {
275
318
  const block = display[blockIndex]!;
276
- const text = displayText(block);
319
+ const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
320
+ // list items of one list stay tight; every other block sits a blank row below its neighbour
321
+ const previous = display[blockIndex - 1];
322
+ const tight =
323
+ previous !== undefined &&
324
+ block.kind === previous.kind &&
325
+ (block.kind === "li" || block.kind === "oli");
326
+ // the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
327
+ // virtualizer's spacer math counts it
328
+ const leadingGap =
329
+ blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
330
+ const measuredBox = (children: React.ReactNode): React.ReactNode => (
331
+ <box
332
+ key={`discussion-block-${blockIndex}`}
333
+ id={`discussion-block-${blockIndex}`}
334
+ ref={virtual.measureRef(blockIndex)}
335
+ style={{ flexDirection: "column" }}
336
+ >
337
+ {leadingGap}
338
+ {children}
339
+ </box>
340
+ );
341
+
342
+ if (isGridKind(block.kind)) {
343
+ // a grid annotates as one unit: every row hit-tests to the same block span, a mark tints the grid,
344
+ // and the block's discussion cards slot under it, so annotations resolving into a grid stay visible
345
+ const gridLine: VisualLine = { start: 0, end: renderedText(block).length };
346
+ const marked = surface.rangesFor(blockIndex).some((range) => !range.caretOnly);
347
+
348
+ return measuredBox(
349
+ <>
350
+ <MarkdownGridBlock
351
+ block={block}
352
+ theme={tokens}
353
+ contentWidth={viewWidth > 0 ? viewWidth - 6 : 40}
354
+ marked={marked}
355
+ markBackdrop={palette.markBackdrop}
356
+ registerRow={(rowIndex) => surface.registerLine(blockIndex, rowIndex, gridLine)}
357
+ onRowMouseDown={surface.onLineMouseDown}
358
+ />
359
+ {surface.cardsAfterLine(blockIndex, gridLine, true)}
360
+ </>,
361
+ );
362
+ }
363
+
364
+ const roleRuns = renderedStyleRuns(block);
277
365
  const ranges = surface.rangesFor(blockIndex);
278
- const { baseFg, baseAttributes, marker, changeTag } = blockStyle(block, tokens);
279
- const lines = wrapLines(text, viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
366
+ const lines = wrapLines(renderedText(block), viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
280
367
  const lineRows: React.ReactNode[] = [];
281
368
 
282
369
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
@@ -286,16 +373,16 @@ export function ThreadView({
286
373
  lineRows.push(
287
374
  lineRowFor({
288
375
  blockIndex,
289
- text,
376
+ roleRuns,
290
377
  line,
291
378
  lineIndex,
292
379
  ranges,
293
380
  marker,
294
381
  baseFg,
295
382
  baseAttributes,
296
- trailing: lineIndex === 0 ? changeTag : null,
297
383
  }),
298
384
  );
385
+ if (isLastLine) lineRows.push(headingRule(block));
299
386
  const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
300
387
 
301
388
  lineRows.push(...cards);
@@ -304,13 +391,7 @@ export function ThreadView({
304
391
  }
305
392
  }
306
393
 
307
- // list items of one list stay tight; every other block sits a blank row
308
- // below its neighbour, and a code block announces its language first
309
- const previous = display[blockIndex - 1];
310
- const tight =
311
- previous !== undefined &&
312
- block.kind === previous.kind &&
313
- (block.kind === "li" || block.kind === "oli");
394
+ // a code block announces its language first
314
395
  const languageRow =
315
396
  block.kind === "code" ? (
316
397
  <text key="language" fg={tokens.textDim} selectable={false}>
@@ -318,22 +399,11 @@ export function ThreadView({
318
399
  </text>
319
400
  ) : null;
320
401
 
321
- // the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
322
- // virtualizer's spacer math counts it
323
- const leadingGap =
324
- blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
325
-
326
- return (
327
- <box
328
- key={`discussion-block-${blockIndex}`}
329
- id={`discussion-block-${blockIndex}`}
330
- ref={virtual.measureRef(blockIndex)}
331
- style={{ flexDirection: "column" }}
332
- >
333
- {leadingGap}
402
+ return measuredBox(
403
+ <>
334
404
  {languageRow}
335
405
  {lineRows}
336
- </box>
406
+ </>,
337
407
  );
338
408
  };
339
409
 
@@ -385,10 +455,14 @@ export function ThreadView({
385
455
  >
386
456
  {virtualBlocks()}
387
457
  </scrollbox>
458
+ {suspended ? null : (
459
+ <NavModeHint navMode={surface.navMode} surface="thread" theme={tokens} />
460
+ )}
388
461
  </box>
389
462
  <DiscussionMarkerRail
390
463
  discussions={discussions}
391
464
  spanQuote={surface.spanQuote}
465
+ focusedKey={surface.focusedDiscussion}
392
466
  onJump={surface.jumpToDiscussion}
393
467
  scrollbox={scrollRef}
394
468
  theme={theme}
@@ -10,3 +10,10 @@ export const ShareLink: Story = {
10
10
  expectedColors: [DARK.accent],
11
11
  size: { width: 60, height: 12 },
12
12
  };
13
+
14
+ export const LongMessage: Story = {
15
+ render: () => (
16
+ <Toast body="could not stop sharing: gateway revoke failed - the share may already be gone, try again in a moment" />
17
+ ),
18
+ size: { width: 90, height: 14 },
19
+ };
@@ -10,17 +10,29 @@ export interface ToastProps {
10
10
  theme?: Theme;
11
11
  }
12
12
 
13
+ /** A toast never spans the screen: a long message wraps inside this, like the settings dialog. */
14
+ const TOAST_MAX_WIDTH = 64;
15
+
13
16
  export function Toast({ title, body, theme }: ToastProps): React.ReactNode {
14
17
  const tokens = useComponentTheme(theme);
15
- const { width: terminalWidth } = useTerminalDimensions();
16
- const width = Math.min(terminalWidth - 6, Math.max(28, body.length + 4));
18
+ const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
19
+ const width = Math.min(
20
+ terminalWidth - 6,
21
+ Math.max(28, Math.min(TOAST_MAX_WIDTH, body.length + 4)),
22
+ );
23
+ // the message wraps at the content width, so grow the box to fit the wrapped lines
24
+ const contentWidth = Math.max(1, width - 4);
25
+ const lines = body
26
+ .split("\n")
27
+ .reduce((total, line) => total + Math.max(1, Math.ceil(line.length / contentWidth)), 0);
28
+ const height = Math.min(terminalHeight - 4, Math.max(6, lines + 4));
17
29
 
18
30
  return (
19
31
  <Dialog
20
32
  isOpen
21
33
  title={title ? ` ${title} ` : undefined}
22
34
  width={width}
23
- height={6}
35
+ height={height}
24
36
  background={tokens.elevated}
25
37
  theme={theme}
26
38
  >