mosage 0.2.0 → 0.8.0

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 (121) hide show
  1. package/README.md +16 -15
  2. package/bin.js +2 -0
  3. package/dist/build-C7NW_3Pk.js +14 -0
  4. package/dist/check-CP4873Wx.js +41 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +228 -0
  7. package/dist/config-DPm1BBAb.js +2619 -0
  8. package/dist/config-TlTe7Ona.d.ts +24 -0
  9. package/dist/context-BqsdSrAQ.js +1084 -0
  10. package/dist/dev-Biz42qlu.js +17 -0
  11. package/dist/diagram-xlVDekYk.js +763 -0
  12. package/dist/export-Bi6nuxjT.js +31 -0
  13. package/dist/import-D2jNB07F.js +25 -0
  14. package/dist/index.d.ts +455 -0
  15. package/dist/index.js +693 -0
  16. package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
  17. package/dist/preview-CLm51aRt.js +19 -0
  18. package/dist/sdk-DjpX6mCv.js +51 -0
  19. package/dist/vite/index.d.ts +25 -0
  20. package/dist/vite/index.js +2 -0
  21. package/env.d.ts +83 -0
  22. package/package.json +59 -12
  23. package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
  24. package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
  25. package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
  26. package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
  27. package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
  28. package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
  29. package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
  30. package/src/app/app.tsx +42 -0
  31. package/src/app/components/data-table.tsx +196 -0
  32. package/src/app/components/design-panel/design-panel.tsx +318 -0
  33. package/src/app/components/design-panel/design-provider.tsx +121 -0
  34. package/src/app/components/design-panel/use-design.ts +85 -0
  35. package/src/app/components/diagram.tsx +76 -0
  36. package/src/app/components/doc-assets.tsx +129 -0
  37. package/src/app/components/doc-search.tsx +248 -0
  38. package/src/app/components/doc-sidebar.tsx +162 -0
  39. package/src/app/components/flow-page.tsx +93 -0
  40. package/src/app/components/footnote.tsx +204 -0
  41. package/src/app/components/image-placeholder.tsx +50 -0
  42. package/src/app/components/inspector/inspector.tsx +518 -0
  43. package/src/app/components/numbering.tsx +224 -0
  44. package/src/app/components/page-frame.tsx +70 -0
  45. package/src/app/components/sidebar/folder-item.tsx +212 -0
  46. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  47. package/src/app/components/sidebar/sidebar.tsx +252 -0
  48. package/src/app/components/table-of-contents.tsx +93 -0
  49. package/src/app/components/theme-toggle.tsx +50 -0
  50. package/src/app/components/themes/markdown.tsx +249 -0
  51. package/src/app/components/themes/theme-preview.tsx +74 -0
  52. package/src/app/components/ui/menu.tsx +143 -0
  53. package/src/app/index.html +12 -0
  54. package/src/app/lib/agent-bridge.ts +140 -0
  55. package/src/app/lib/assets.ts +151 -0
  56. package/src/app/lib/design-presets.ts +109 -0
  57. package/src/app/lib/design.ts +88 -0
  58. package/src/app/lib/diagnostics.ts +282 -0
  59. package/src/app/lib/doc-preview.tsx +29 -0
  60. package/src/app/lib/docs.ts +26 -0
  61. package/src/app/lib/docx/extract.ts +1623 -0
  62. package/src/app/lib/docx/fonts.test.ts +136 -0
  63. package/src/app/lib/docx/fonts.ts +166 -0
  64. package/src/app/lib/docx/media.ts +102 -0
  65. package/src/app/lib/docx/model.ts +206 -0
  66. package/src/app/lib/docx/paragraph.test.ts +92 -0
  67. package/src/app/lib/docx/paragraph.ts +107 -0
  68. package/src/app/lib/docx/props.ts +187 -0
  69. package/src/app/lib/docx/styles.ts +306 -0
  70. package/src/app/lib/docx/units.ts +35 -0
  71. package/src/app/lib/docx/write.test.ts +507 -0
  72. package/src/app/lib/docx/write.ts +581 -0
  73. package/src/app/lib/docx/xml.ts +39 -0
  74. package/src/app/lib/export-docx.ts +289 -0
  75. package/src/app/lib/export-dom.ts +318 -0
  76. package/src/app/lib/export-html.ts +156 -0
  77. package/src/app/lib/export-image.ts +70 -0
  78. package/src/app/lib/export-pdf.ts +165 -0
  79. package/src/app/lib/flow-measure.test.ts +31 -0
  80. package/src/app/lib/flow-measure.ts +183 -0
  81. package/src/app/lib/flow.test.ts +110 -0
  82. package/src/app/lib/flow.ts +136 -0
  83. package/src/app/lib/folders.ts +192 -0
  84. package/src/app/lib/footnotes.test.tsx +102 -0
  85. package/src/app/lib/footnotes.ts +94 -0
  86. package/src/app/lib/inspector/fiber.ts +99 -0
  87. package/src/app/lib/labels.test.ts +18 -0
  88. package/src/app/lib/labels.ts +181 -0
  89. package/src/app/lib/outline.ts +118 -0
  90. package/src/app/lib/page-context.tsx +43 -0
  91. package/src/app/lib/page-range.test.ts +95 -0
  92. package/src/app/lib/page-range.ts +90 -0
  93. package/src/app/lib/print-ready.ts +69 -0
  94. package/src/app/lib/rasterize.ts +173 -0
  95. package/src/app/lib/scan.ts +26 -0
  96. package/src/app/lib/sdk.test.ts +32 -0
  97. package/src/app/lib/sdk.ts +115 -0
  98. package/src/app/lib/themes.ts +31 -0
  99. package/src/app/lib/use-doc-module.ts +53 -0
  100. package/src/app/lib/use-doc-pages.ts +147 -0
  101. package/src/app/lib/utils.ts +6 -0
  102. package/src/app/lib/view-mode.test.ts +91 -0
  103. package/src/app/lib/view-mode.ts +104 -0
  104. package/src/app/main.tsx +14 -0
  105. package/src/app/routes/assets.tsx +257 -0
  106. package/src/app/routes/doc.tsx +877 -0
  107. package/src/app/routes/home-shell.tsx +203 -0
  108. package/src/app/routes/home.tsx +269 -0
  109. package/src/app/routes/themes.tsx +121 -0
  110. package/src/app/styles.css +97 -0
  111. package/src/app/virtual.d.ts +30 -0
  112. package/template/AGENTS.md +1 -1
  113. package/template/README.md +24 -52
  114. package/template/docs/getting-started/index.tsx +2 -2
  115. package/template/mosage.config.ts +1 -1
  116. package/template/package.json +1 -1
  117. package/template/tsconfig.json +1 -1
  118. package/README.zh-TW.md +0 -28
  119. /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
  120. /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
  121. /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
@@ -0,0 +1,518 @@
1
+ import { Check, Loader2, MessageSquarePlus, X } from 'lucide-react';
2
+ import {
3
+ type CSSProperties,
4
+ Fragment,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { candidateLocs, formatLocs } from '../../lib/inspector/fiber';
13
+ import { TOC_ENTRY_ATTR } from '../table-of-contents';
14
+
15
+ type TextPart = { kind: 'text'; index: number; value: string } | { kind: 'markup'; label: string };
16
+
17
+ type ResolvedTarget = {
18
+ line: number;
19
+ column: number;
20
+ editable: boolean;
21
+ text: string;
22
+ parts: TextPart[];
23
+ reason?: string;
24
+ };
25
+
26
+ export const LOC_ATTR = 'data-od-loc';
27
+
28
+ export type InspectorTarget = {
29
+ line: number;
30
+ column: number;
31
+ anchor: HTMLElement;
32
+ tag: string;
33
+ };
34
+
35
+ // Same visual language as open-slide's inspector: dashed on hover, solid on
36
+ // selection, both in the same blue.
37
+ const FRAME_STYLE: Record<'hover' | 'selected', CSSProperties> = {
38
+ hover: { outline: '1.5px dashed #3b82f6', background: 'rgba(59,130,246,0.05)' },
39
+ selected: { outline: '2px solid #3b82f6', background: 'rgba(59,130,246,0.1)' },
40
+ };
41
+
42
+ function normalize(value: string): string {
43
+ return value.replace(/\s+/g, ' ').trim();
44
+ }
45
+
46
+ function targetFrom(el: Element | null): InspectorTarget | null {
47
+ // A contents row is generated from a heading and written nowhere; the words
48
+ // to edit are the heading's. Clicking the row selects that instead, so the
49
+ // list stays what it is — a view — and still answers to a click.
50
+ const entry = el?.closest?.(`[${TOC_ENTRY_ATTR}]`)?.getAttribute(TOC_ENTRY_ATTR);
51
+ const heading = entry ? document.getElementById(entry) : null;
52
+ const from = heading ?? el;
53
+ const host = (from as HTMLElement | null)?.closest?.(`[${LOC_ATTR}]`) as HTMLElement | null;
54
+ const raw = host?.getAttribute(LOC_ATTR);
55
+ if (!host || !raw) return null;
56
+ const [line, column] = raw.split(':').map(Number);
57
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return null;
58
+ return { line, column, anchor: host, tag: host.tagName.toLowerCase() };
59
+ }
60
+
61
+ type Rect = { left: number; top: number; width: number; height: number };
62
+
63
+ function sameRect(a: Rect | null, b: Rect) {
64
+ return (
65
+ a !== null &&
66
+ a.left === b.left &&
67
+ a.top === b.top &&
68
+ a.width === b.width &&
69
+ a.height === b.height
70
+ );
71
+ }
72
+
73
+ function Frame({
74
+ anchor,
75
+ container,
76
+ variant,
77
+ }: {
78
+ anchor: HTMLElement | null;
79
+ container: HTMLElement;
80
+ variant: 'hover' | 'selected';
81
+ }) {
82
+ const [rect, setRect] = useState<Rect | null>(null);
83
+
84
+ // Deliberately no dependency array. Opening a side pane re-zooms and re-centres
85
+ // the pages, which moves the anchor without resizing it — ResizeObserver never
86
+ // sees that, and the zoom lands a frame after the observer would have fired.
87
+ // Measuring after every render is what keeps the frame on its element; the
88
+ // value comparison below stops that from looping.
89
+ useLayoutEffect(() => {
90
+ if (!anchor?.isConnected) {
91
+ setRect(null);
92
+ return;
93
+ }
94
+ // The overlay is absolutely positioned inside the scroller, so its origin
95
+ // is the content box — frames live in content coordinates and scroll along
96
+ // with the pages.
97
+ const measure = () => {
98
+ const a = anchor.getBoundingClientRect();
99
+ const c = container.getBoundingClientRect();
100
+ const next = {
101
+ left: a.left - c.left + container.scrollLeft,
102
+ top: a.top - c.top + container.scrollTop,
103
+ width: a.width,
104
+ height: a.height,
105
+ };
106
+ setRect((prev) => (sameRect(prev, next) ? prev : next));
107
+ };
108
+
109
+ measure();
110
+ const ro = new ResizeObserver(measure);
111
+ ro.observe(anchor);
112
+ ro.observe(container);
113
+ container.addEventListener('scroll', measure, { passive: true });
114
+ return () => {
115
+ ro.disconnect();
116
+ container.removeEventListener('scroll', measure);
117
+ };
118
+ });
119
+
120
+ if (!rect) return null;
121
+ return (
122
+ <div
123
+ aria-hidden
124
+ className="pointer-events-none absolute rounded-[2px]"
125
+ style={{ ...rect, ...FRAME_STYLE[variant] }}
126
+ />
127
+ );
128
+ }
129
+
130
+ type Props = {
131
+ docId: string;
132
+ /** The scroll container the pages live in. */
133
+ containerRef: React.RefObject<HTMLDivElement | null>;
134
+ onExit: () => void;
135
+ };
136
+
137
+ export function Inspector({ docId, containerRef, onExit }: Props) {
138
+ const [container, setContainer] = useState<HTMLElement | null>(null);
139
+ const [hover, setHover] = useState<InspectorTarget | null>(null);
140
+ const [selected, setSelected] = useState<InspectorTarget | null>(null);
141
+ const [target, setTarget] = useState<ResolvedTarget | null>(null);
142
+ const [drafts, setDrafts] = useState<Record<number, string>>({});
143
+ const [note, setNote] = useState('');
144
+ const [busy, setBusy] = useState(false);
145
+ const [status, setStatus] = useState<string | null>(null);
146
+ const panelRef = useRef<HTMLElement>(null);
147
+ const editorRef = useRef<HTMLDivElement>(null);
148
+
149
+ useLayoutEffect(() => setContainer(containerRef.current), [containerRef]);
150
+
151
+ const insidePanel = useCallback(
152
+ (node: EventTarget | null) => panelRef.current?.contains(node as Node) ?? false,
153
+ [],
154
+ );
155
+
156
+ useEffect(() => {
157
+ if (!container) return;
158
+
159
+ const onMove = (e: PointerEvent) => {
160
+ if (insidePanel(e.target)) return;
161
+ setHover(targetFrom(e.target as Element));
162
+ };
163
+ const onClick = (e: MouseEvent) => {
164
+ if (insidePanel(e.target)) return;
165
+ const target = targetFrom(e.target as Element);
166
+ if (!target) return;
167
+ e.preventDefault();
168
+ e.stopPropagation();
169
+ // A contents row sends the selection to a heading pages away. Without
170
+ // this the panel fills in and the document sits where it was, which
171
+ // reads as nothing having happened.
172
+ if (!target.anchor.contains(e.target as Node)) {
173
+ target.anchor.scrollIntoView({ block: 'center', behavior: 'smooth' });
174
+ }
175
+ setSelected(target);
176
+ setStatus(null);
177
+ setNote('');
178
+ };
179
+ const onKey = (e: KeyboardEvent) => {
180
+ if (e.key !== 'Escape') return;
181
+ if (selected) {
182
+ setSelected(null);
183
+ return;
184
+ }
185
+ onExit();
186
+ };
187
+
188
+ container.addEventListener('pointermove', onMove, true);
189
+ container.addEventListener('click', onClick, true);
190
+ window.addEventListener('keydown', onKey, true);
191
+ return () => {
192
+ container.removeEventListener('pointermove', onMove, true);
193
+ container.removeEventListener('click', onClick, true);
194
+ window.removeEventListener('keydown', onKey, true);
195
+ };
196
+ }, [container, insidePanel, onExit, selected]);
197
+
198
+ // Report the pick to the dev server so `current.json` can answer "this
199
+ // element" for an agent. Clearing the selection clears it there too.
200
+ useEffect(() => {
201
+ if (!import.meta.hot) return;
202
+ import.meta.hot.send('mosage:current', {
203
+ selection: selected
204
+ ? {
205
+ line: selected.line,
206
+ column: selected.column,
207
+ tagName: selected.anchor.tagName.toLowerCase(),
208
+ text: normalize(selected.anchor.textContent ?? '').slice(0, 120),
209
+ }
210
+ : null,
211
+ });
212
+ }, [selected]);
213
+
214
+ // Read the element's text from source, not from the DOM: only source can say
215
+ // whether it is a single editable text child or markup we must not touch.
216
+ useEffect(() => {
217
+ if (!selected) {
218
+ setTarget(null);
219
+ setDrafts({});
220
+ return;
221
+ }
222
+ let cancelled = false;
223
+ const params = new URLSearchParams({
224
+ docId,
225
+ locs: formatLocs(candidateLocs(selected.anchor)),
226
+ shown: normalize(selected.anchor.textContent ?? '').slice(0, 400),
227
+ });
228
+ fetch(`/__edit/text?${params}`)
229
+ .then((res) => res.json())
230
+ .then((body: ResolvedTarget & { error?: string }) => {
231
+ if (cancelled) return;
232
+ if (body.error) {
233
+ setTarget(null);
234
+ setStatus(body.error);
235
+ return;
236
+ }
237
+ // Last line of defence: every run the source offers has to be visible
238
+ // in the element the user clicked. A drifted resolution would
239
+ // otherwise let a save rewrite someone else's words.
240
+ const shownText = normalize(selected.anchor.textContent ?? '');
241
+ const runs = (body.parts ?? []).filter((part) => part.kind === 'text');
242
+ const belongs =
243
+ runs.length > 0 &&
244
+ runs.every((part) => part.kind === 'text' && shownText.includes(normalize(part.value)));
245
+ setTarget(
246
+ body.editable && !belongs
247
+ ? { ...body, editable: false, reason: 'source text does not match this element' }
248
+ : body,
249
+ );
250
+ setDrafts({});
251
+ })
252
+ .catch(() => {
253
+ if (!cancelled) setStatus('could not read source');
254
+ });
255
+ return () => {
256
+ cancelled = true;
257
+ };
258
+ }, [selected, docId]);
259
+
260
+ const textParts = (target?.parts ?? []).filter(
261
+ (part): part is Extract<TextPart, { kind: 'text' }> => part.kind === 'text',
262
+ );
263
+ const dirty = textParts.filter(
264
+ (part) => drafts[part.index] !== undefined && drafts[part.index] !== part.value,
265
+ );
266
+
267
+ const saveText = async () => {
268
+ if (!target || !selected || dirty.length === 0 || busy) return;
269
+ setBusy(true);
270
+ try {
271
+ for (const part of dirty) {
272
+ const res = await fetch('/__edit/text', {
273
+ method: 'PUT',
274
+ headers: { 'content-type': 'application/json' },
275
+ body: JSON.stringify({
276
+ docId,
277
+ line: target.line,
278
+ column: target.column,
279
+ index: part.index,
280
+ expected: part.value,
281
+ shown: normalize(selected.anchor.textContent ?? ''),
282
+ text: drafts[part.index],
283
+ }),
284
+ });
285
+ const body = (await res.json()) as { ok?: boolean; error?: string };
286
+ if (!res.ok || !body.ok) {
287
+ setStatus(body.error ?? 'Save failed');
288
+ return;
289
+ }
290
+ }
291
+ setStatus('Saved to source');
292
+ // Editing shifts what follows, so the captured location is good for one
293
+ // write. Reselect to edit again.
294
+ setSelected(null);
295
+ } finally {
296
+ setBusy(false);
297
+ }
298
+ };
299
+
300
+ const saveComment = async () => {
301
+ if (!selected || note.trim() === '' || busy) return;
302
+ setBusy(true);
303
+ try {
304
+ const res = await fetch('/__edit/comment', {
305
+ method: 'POST',
306
+ headers: { 'content-type': 'application/json' },
307
+ body: JSON.stringify({
308
+ docId,
309
+ line: target?.line ?? selected.line,
310
+ column: target?.column ?? selected.column,
311
+ note,
312
+ hint: selected.tag,
313
+ }),
314
+ });
315
+ const body = (await res.json()) as { ok?: boolean; error?: string };
316
+ if (res.ok && body.ok) {
317
+ setNote('');
318
+ setStatus('Marked in source — run /apply-comments');
319
+ } else {
320
+ setStatus(body.error ?? 'Could not add comment');
321
+ }
322
+ } finally {
323
+ setBusy(false);
324
+ }
325
+ };
326
+
327
+ const overlay = container
328
+ ? createPortal(
329
+ <>
330
+ <div className="pointer-events-none absolute inset-0 z-30">
331
+ <Frame
332
+ anchor={hover?.anchor === selected?.anchor ? null : (hover?.anchor ?? null)}
333
+ container={container}
334
+ variant="hover"
335
+ />
336
+ <Frame anchor={selected?.anchor ?? null} container={container} variant="selected" />
337
+ </div>
338
+ <div className="pointer-events-none sticky bottom-3 z-40 mx-auto w-fit rounded-full bg-foreground/90 px-3 py-1.5 text-[11px] text-background">
339
+ {selected ? 'Esc to deselect' : 'Click an element · Esc to exit'}
340
+ </div>
341
+ </>,
342
+ container,
343
+ )
344
+ : null;
345
+
346
+ return (
347
+ <>
348
+ {overlay}
349
+ {selected && (
350
+ <aside
351
+ ref={panelRef}
352
+ className="flex w-72 flex-none flex-col border-border border-l bg-background"
353
+ >
354
+ <header className="flex h-10 flex-none items-center justify-between border-border border-b px-3">
355
+ <span className="font-mono text-[11px] text-muted-foreground">
356
+ &lt;{selected.tag}&gt; · {target?.line ?? selected.line}:
357
+ {target?.column ?? selected.column}
358
+ </span>
359
+ <button
360
+ type="button"
361
+ aria-label="Close"
362
+ onClick={() => setSelected(null)}
363
+ className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
364
+ >
365
+ <X className="size-3.5" />
366
+ </button>
367
+ </header>
368
+
369
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
370
+ <span className="block text-[10px] text-muted-foreground uppercase tracking-wider">
371
+ Text
372
+ </span>
373
+ {target === null ? (
374
+ <div className="grid h-16 place-items-center">
375
+ <Loader2 className="size-3.5 animate-spin text-muted-foreground" />
376
+ </div>
377
+ ) : target.editable ? (
378
+ <>
379
+ {/*
380
+ * One field, not one per run. A sentence interrupted by five
381
+ * <code> spans is still one sentence, and bordering each
382
+ * fragment turned a paragraph into eleven stacked boxes.
383
+ *
384
+ * The runs are laid out as the sentence reads, with the markup
385
+ * between them as inert chips. Nothing re-renders while typing:
386
+ * the children never change, so React leaves the DOM — and the
387
+ * caret — alone, and the text is read back out of it.
388
+ */}
389
+ {target.parts.length > 1 ? (
390
+ // biome-ignore lint/a11y/useSemanticElements: a textarea cannot hold the inert markup chips
391
+ <div
392
+ key={`${target.line}:${target.column}`}
393
+ ref={editorRef}
394
+ contentEditable
395
+ suppressContentEditableWarning
396
+ role="textbox"
397
+ tabIndex={0}
398
+ aria-label="Text"
399
+ onInput={() => {
400
+ const host = editorRef.current;
401
+ if (!host) return;
402
+ const next: Record<number, string> = {};
403
+ for (const run of host.querySelectorAll<HTMLElement>('[data-run]')) {
404
+ next[Number(run.dataset.run)] = run.textContent ?? '';
405
+ }
406
+ setDrafts(next);
407
+ }}
408
+ onKeyDown={(e) => {
409
+ // A newline would be a <br> the runs cannot carry.
410
+ if (e.key === 'Enter') {
411
+ e.preventDefault();
412
+ void saveText();
413
+ }
414
+ }}
415
+ className="mt-1 rounded border border-border px-2 py-1.5 text-xs leading-relaxed outline-none focus-within:border-foreground/40"
416
+ >
417
+ {target.parts.map((part, index) =>
418
+ part.kind === 'markup' ? (
419
+ // A chip carries no whitespace, so `real<code>/<code>`
420
+ // is one unbreakable word without these.
421
+ // biome-ignore lint/suspicious/noArrayIndexKey: parts are positional
422
+ <Fragment key={`m${index}`}>
423
+ <wbr />
424
+ <span
425
+ contentEditable={false}
426
+ title="Markup is kept as written"
427
+ className="mx-0.5 select-none rounded bg-muted px-1 py-px font-mono text-[10px] text-muted-foreground"
428
+ >
429
+ {part.label}
430
+ </span>
431
+ <wbr />
432
+ </Fragment>
433
+ ) : (
434
+ <span key={`t${part.index}`} data-run={part.index}>
435
+ {part.value}
436
+ </span>
437
+ ),
438
+ )}
439
+ </div>
440
+ ) : (
441
+ target.parts.map((part) =>
442
+ part.kind === 'markup' ? null : (
443
+ <textarea
444
+ key={`t${part.index}`}
445
+ value={drafts[part.index] ?? part.value}
446
+ onChange={(e) =>
447
+ setDrafts((prev) => ({ ...prev, [part.index]: e.target.value }))
448
+ }
449
+ onKeyDown={(e) => {
450
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void saveText();
451
+ }}
452
+ rows={Math.min(
453
+ 14,
454
+ Math.max(
455
+ 2,
456
+ (drafts[part.index] ?? part.value).split('\n').length,
457
+ Math.ceil((drafts[part.index] ?? part.value).length / 40),
458
+ ),
459
+ )}
460
+ className={`mt-1 w-full resize-y rounded border border-border bg-transparent px-2 py-1.5 outline-none focus:border-foreground/40 ${
461
+ selected?.tag === 'pre'
462
+ ? 'whitespace-pre font-mono text-[11px]'
463
+ : 'text-xs'
464
+ }`}
465
+ />
466
+ ),
467
+ )
468
+ )}
469
+ <button
470
+ type="button"
471
+ onClick={saveText}
472
+ disabled={busy || dirty.length === 0}
473
+ className="mt-1.5 flex w-full items-center justify-center gap-1.5 rounded bg-primary px-2 py-1.5 text-primary-foreground text-xs disabled:opacity-50"
474
+ >
475
+ {busy ? (
476
+ <Loader2 className="size-3 animate-spin" />
477
+ ) : (
478
+ <Check className="size-3" />
479
+ )}
480
+ {dirty.length > 1 ? `Save ${dirty.length} edits` : 'Save text'}
481
+ </button>
482
+ </>
483
+ ) : (
484
+ <p className="mt-1 rounded border border-border bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">
485
+ {target.reason ?? 'Not editable here.'}
486
+ </p>
487
+ )}
488
+
489
+ <span className="mt-4 block text-[10px] text-muted-foreground uppercase tracking-wider">
490
+ Comment for the agent
491
+ </span>
492
+ <textarea
493
+ value={note}
494
+ onChange={(e) => setNote(e.target.value)}
495
+ onKeyDown={(e) => {
496
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void saveComment();
497
+ }}
498
+ rows={3}
499
+ placeholder="make this bold, shorten to one line…"
500
+ className="mt-1 w-full resize-y rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/60 focus:border-foreground/40"
501
+ />
502
+ <button
503
+ type="button"
504
+ onClick={saveComment}
505
+ disabled={busy || note.trim() === ''}
506
+ className="mt-1.5 flex w-full items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs transition-colors hover:bg-accent disabled:opacity-50"
507
+ >
508
+ <MessageSquarePlus className="size-3" />
509
+ Mark comment
510
+ </button>
511
+
512
+ {status && <p className="mt-2 text-[11px] text-muted-foreground">{status}</p>}
513
+ </div>
514
+ </aside>
515
+ )}
516
+ </>
517
+ );
518
+ }