@agimon-ai/po-author 0.0.0-alpha.40 → 0.0.0-alpha.41

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.
@@ -606,6 +606,118 @@ function resolveAuthorGridCell(sessionId, cell, expectedGeometryToken) {
606
606
  }
607
607
  };
608
608
  }
609
+ //#endregion
610
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/authorMarkdownText.ts
611
+ const HEADING = /^#{1,6} /;
612
+ const LIST_ITEM = /^(?:[-*+]|\d+[.)]) /;
613
+ /**
614
+ * One formatting edit for the selected source range. Bold, Heading and List toggle off when already applied;
615
+ * Heading and List apply to every touched line and replace each other.
616
+ */
617
+ function authorMarkdownFormat(content, from, to, format) {
618
+ if (format === "bold") {
619
+ const selected = content.slice(from, to);
620
+ if (selected.length >= 4 && selected.startsWith("**") && selected.endsWith("**")) return {
621
+ from,
622
+ to,
623
+ insert: selected.slice(2, -2),
624
+ select: {
625
+ from,
626
+ to: to - 4
627
+ }
628
+ };
629
+ if (from >= 2 && content.slice(from - 2, from) === "**" && content.slice(to, to + 2) === "**") return {
630
+ from: from - 2,
631
+ to: to + 2,
632
+ insert: selected,
633
+ select: {
634
+ from: from - 2,
635
+ to: to - 2
636
+ }
637
+ };
638
+ const text = selected || "bold text";
639
+ return {
640
+ from,
641
+ to,
642
+ insert: `**${text}**`,
643
+ select: {
644
+ from: from + 2,
645
+ to: from + 2 + text.length
646
+ }
647
+ };
648
+ }
649
+ if (format === "link") {
650
+ const text = content.slice(from, to) || "link text";
651
+ const url = from + text.length + 3;
652
+ return {
653
+ from,
654
+ to,
655
+ insert: `[${text}](https://)`,
656
+ select: {
657
+ from: url,
658
+ to: url + 8
659
+ }
660
+ };
661
+ }
662
+ const start = content.lastIndexOf("\n", from - 1) + 1;
663
+ const last = to > from && content[to - 1] === "\n" ? to - 1 : to;
664
+ const lineEnd = content.indexOf("\n", Math.max(last, start));
665
+ const end = lineEnd === -1 ? content.length : lineEnd;
666
+ const own = format === "heading" ? HEADING : LIST_ITEM;
667
+ const prefix = format === "heading" ? "## " : "- ";
668
+ const lines = content.slice(start, end).split("\n");
669
+ const written = lines.filter((line) => line.trim() !== "");
670
+ const remove = written.length > 0 && written.every((line) => own.test(line));
671
+ const insert = lines.map((line) => line.trim() === "" ? line : remove ? line.replace(own, "") : prefix + line.replace(HEADING, "").replace(LIST_ITEM, "")).join("\n");
672
+ return {
673
+ from: start,
674
+ to: end,
675
+ insert,
676
+ select: {
677
+ from: start,
678
+ to: start + insert.length
679
+ }
680
+ };
681
+ }
682
+ /**
683
+ * Moves a source range through one edit from `before` to `after`. Text typed just outside the range stays
684
+ * outside; text replaced inside it widens the range to the new text, so a note follows the passage it names.
685
+ */
686
+ function rebaseTextRange(before, after, start, end) {
687
+ const limit = Math.min(before.length, after.length);
688
+ let prefix = 0;
689
+ while (prefix < limit && before[prefix] === after[prefix]) prefix += 1;
690
+ let suffix = 0;
691
+ while (suffix < limit - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix += 1;
692
+ const changedEnd = before.length - suffix;
693
+ const delta = after.length - before.length;
694
+ const startAt = start < prefix ? start : start >= changedEnd ? start + delta : prefix;
695
+ const endAt = end <= prefix ? end : end >= changedEnd ? end + delta : after.length - suffix;
696
+ return {
697
+ start: startAt,
698
+ end: Math.max(startAt, endAt)
699
+ };
700
+ }
701
+ /**
702
+ * Where each rendered text run starts in the Markdown source, searched in reading order.
703
+ * Undefined when a run is blank or not verbatim source (entities, generated labels).
704
+ */
705
+ function locateRenderedRuns(source, runs) {
706
+ let cursor = 0;
707
+ return runs.map((run) => {
708
+ if (run.trim() === "") return void 0;
709
+ const whole = source.indexOf(run, cursor);
710
+ const first = run.trimStart().split("\n")[0];
711
+ const at = whole === -1 ? source.indexOf(first, cursor) : whole;
712
+ if (at === -1) return void 0;
713
+ cursor = at + (whole === -1 ? first.length : run.length);
714
+ return at;
715
+ });
716
+ }
717
+ /** One-based source line of an offset. */
718
+ function authorSourceLine(source, offset) {
719
+ return source.slice(0, offset).split("\n").length;
720
+ }
609
721
  const TERMINAL_REQUEST_STATUSES = /* @__PURE__ */ new Set([
610
722
  "COMPLETE",
611
723
  "FAILED",
@@ -749,28 +861,40 @@ function putAuthorDocument(sessionId, input) {
749
861
  staleThumbnails.forEach(revokeThumbnail);
750
862
  return result;
751
863
  }
752
- /** Claims focus and returns a generation token that makes cleanup race-safe. */
864
+ /**
865
+ * Claims focus and returns a generation token that makes cleanup race-safe.
866
+ * Claims stack: a popup review over a document tab hands focus back to the tab
867
+ * when it closes, so the Author dock face stays for the document still shown.
868
+ */
753
869
  function focusAuthorDocument(sessionId, path) {
754
870
  let generation = 0;
755
871
  updateSession(sessionId, (session) => {
756
872
  generation = session.generation + 1;
873
+ const focusedDocument = {
874
+ path: normalizeAuthorPath(path),
875
+ generation,
876
+ focusedAt: Date.now()
877
+ };
757
878
  return {
758
879
  ...session,
759
880
  generation,
760
- focusedDocument: {
761
- path: normalizeAuthorPath(path),
762
- generation,
763
- focusedAt: Date.now()
764
- }
881
+ focusedDocument,
882
+ focusClaims: [...session.focusClaims ?? [], focusedDocument]
765
883
  };
766
884
  });
767
885
  return generation;
768
886
  }
769
887
  function releaseAuthorDocumentFocus(sessionId, generation) {
770
- updateSession(sessionId, (session) => session.focusedDocument?.generation === generation ? {
771
- ...session,
772
- focusedDocument: void 0
773
- } : session);
888
+ updateSession(sessionId, (session) => {
889
+ const claims = session.focusClaims ?? [];
890
+ if (!claims.some((claim) => claim.generation === generation)) return session;
891
+ const focusClaims = claims.filter((claim) => claim.generation !== generation);
892
+ return {
893
+ ...session,
894
+ focusedDocument: focusClaims.at(-1),
895
+ focusClaims
896
+ };
897
+ });
774
898
  }
775
899
  function seekAuthorVideo(sessionId, path, timeSeconds) {
776
900
  const normalized = normalizeAuthorPath(path);
@@ -1017,12 +1141,14 @@ function updateDocument(sessionId, path, update) {
1017
1141
  const next = update(document);
1018
1142
  const session = state.sessions[sessionId];
1019
1143
  const identityChanged = document.version !== next.version || document.sourceSha256 !== next.sourceSha256;
1020
- const collection = identityChanged ? session?.annotationsByDocument[document.path] : void 0;
1144
+ const current = identityChanged ? session?.annotationsByDocument[document.path] : void 0;
1145
+ const rebased = current && rebaseTextCollection(current, document, next);
1146
+ const collection = rebased === void 0 ? current : void 0;
1021
1147
  if (collection !== void 0) {
1022
1148
  if (collection.candidate?.thumbnailUrl !== void 0) staleThumbnails.push(collection.candidate.thumbnailUrl);
1023
1149
  staleThumbnails.push(...collection.annotations.flatMap((annotation) => annotation.thumbnailUrl === void 0 ? [] : [annotation.thumbnailUrl]));
1024
1150
  }
1025
- const staleCollection = identityChanged ? staleAuthorCollection(collection, Date.now()) : void 0;
1151
+ const staleCollection = rebased ?? (identityChanged ? staleAuthorCollection(collection, Date.now()) : void 0);
1026
1152
  const sessions = identityChanged && session !== void 0 ? {
1027
1153
  ...state.sessions,
1028
1154
  [sessionId]: {
@@ -1091,6 +1217,40 @@ function staleAuthorCollection(collection, updatedAt) {
1091
1217
  updatedAt
1092
1218
  };
1093
1219
  }
1220
+ /**
1221
+ * Carries unsent text notes through a local edit or save instead of dropping them: each range follows its
1222
+ * passage and its quote is re-read. Undefined when the collection is already stale or holds a non-text anchor.
1223
+ */
1224
+ function rebaseTextCollection(collection, before, after) {
1225
+ if (collection.stale || collection.revision !== before.version || collection.sourceSha256 !== before.sourceSha256 || before.content === void 0 || after.content === void 0) return void 0;
1226
+ if ([...collection.annotations, ...collection.candidate ? [collection.candidate] : []].some((item) => item.anchor.kind !== "text-range")) return void 0;
1227
+ const source = after.content;
1228
+ const rebase = (item) => {
1229
+ const anchor = item.anchor;
1230
+ const { start, end } = rebaseTextRange(before.content, source, anchor.startOffset, anchor.endOffset);
1231
+ return {
1232
+ ...copyCandidate(item),
1233
+ revision: after.version,
1234
+ sourceSha256: after.sourceSha256,
1235
+ quote: source.slice(start, end),
1236
+ anchor: {
1237
+ ...anchor,
1238
+ startOffset: start,
1239
+ endOffset: end,
1240
+ startLine: authorSourceLine(source, start),
1241
+ endLine: authorSourceLine(source, end)
1242
+ }
1243
+ };
1244
+ };
1245
+ return {
1246
+ ...collection,
1247
+ revision: after.version,
1248
+ sourceSha256: after.sourceSha256,
1249
+ candidate: collection.candidate && rebase(collection.candidate),
1250
+ annotations: collection.annotations.map(rebase),
1251
+ updatedAt: Date.now()
1252
+ };
1253
+ }
1094
1254
  function updateCollection(sessionId, path, update) {
1095
1255
  const normalized = normalizeAuthorPath(path);
1096
1256
  const document = authorDocument(sessionId, normalized);
@@ -1172,7 +1332,9 @@ function revokeThumbnail(url) {
1172
1332
  if (!url.startsWith("blob:")) return;
1173
1333
  try {
1174
1334
  URL.revokeObjectURL(url);
1175
- } catch {}
1335
+ } catch (error) {
1336
+ console.warn(`Author could not release a thumbnail: ${error instanceof Error ? error.message : String(error)}`);
1337
+ }
1176
1338
  }
1177
1339
  //#endregion
1178
1340
  //#region src/extensions/workspaces/sessions/(frontend)/channel/_lib/authorStore.ts
@@ -1217,11 +1379,6 @@ var author_webmcp_web_default = defineChannelFile({
1217
1379
  }
1218
1380
  });
1219
1381
  //#endregion
1220
- //#region src/constants/author.ts
1221
- const AUTHOR_FILE_TAB_PREFIX = "author-file-";
1222
- /** Names a file in the session's artifact folder rather than the working tree. Artifacts open read-only. */
1223
- const AUTHOR_ARTIFACT_PREFIX = "artifact:";
1224
- //#endregion
1225
1382
  //#region src/extensions/workspaces/sessions/(frontend)/_components/AuthorGridOverlay.tsx
1226
1383
  function autonomousVoiceGridVisible(statuses) {
1227
1384
  return statuses["po-voice"]?.trimStart().startsWith("voice auto:") === true;
@@ -1690,6 +1847,10 @@ function AuthorRegionDrafts({ sessionId, path, drafts }) {
1690
1847
  " as an unsent annotation."
1691
1848
  ]
1692
1849
  }),
1850
+ drafts.candidate.quote ? /* @__PURE__ */ jsx("blockquote", {
1851
+ className: "line-clamp-3 border-l-2 border-po-edge-magenta pl-2 text-base text-po-text sm:text-sm",
1852
+ children: drafts.candidate.quote
1853
+ }) : null,
1693
1854
  drafts.candidate.anchor.kind === "video-time-rect" || drafts.candidate.anchor.kind === "video-time-point" ? /* @__PURE__ */ jsxs("p", {
1694
1855
  className: "text-base text-po-dim sm:text-sm",
1695
1856
  children: [
@@ -1761,8 +1922,8 @@ function AuthorRegionDrafts({ sessionId, path, drafts }) {
1761
1922
  }),
1762
1923
  region.comment,
1763
1924
  /* @__PURE__ */ jsx("span", {
1764
- className: "block text-sm text-po-dim",
1765
- children: region.mode === "point" ? "Point" : "Region"
1925
+ className: "line-clamp-2 block text-sm text-po-dim",
1926
+ children: region.quote ? `“${region.quote}”` : region.mode === "point" ? "Point" : "Region"
1766
1927
  })
1767
1928
  ]
1768
1929
  }),
@@ -2067,6 +2228,191 @@ function AuthorRequestLog({ requests }) {
2067
2228
  });
2068
2229
  }
2069
2230
  //#endregion
2231
+ //#region src/extensions/workspaces/sessions/(frontend)/dock/_components/AuthorToolPalette.tsx
2232
+ /** One tool tile. An active annotation tool is the person's mark (rose); an active view tool is Po's jade. */
2233
+ function authorToolClassName(active, annotates) {
2234
+ return `h-auto min-h-11 min-w-11 flex-col gap-1 rounded-md px-1 py-2 text-base [@media(pointer:fine)]:min-h-8 ${!active ? "border-po-border-soft bg-po-panel text-po-dim" : annotates ? "border-po-edge-magenta bg-po-tint-magenta font-semibold text-po-magenta hover:text-po-magenta" : "border-po-edge-green bg-po-tint-green font-semibold text-po-green hover:text-po-green"}`;
2235
+ }
2236
+ function AuthorToolPalette({ sessionId, path, kind, activeTool }) {
2237
+ const text = kind === "markdown" || kind === "text";
2238
+ const tools = [
2239
+ ...[
2240
+ "image",
2241
+ "pdf",
2242
+ "video",
2243
+ "story-preview"
2244
+ ].includes(kind) ? [{
2245
+ label: "Pan",
2246
+ glyph: "✋",
2247
+ action: () => setAuthorToolMode(sessionId, path, activeTool === "pan" ? "select" : "pan"),
2248
+ active: activeTool === "pan",
2249
+ annotates: false
2250
+ }] : [],
2251
+ ...[
2252
+ "image",
2253
+ "pdf",
2254
+ "video",
2255
+ "story-preview",
2256
+ "html"
2257
+ ].includes(kind) ? [{
2258
+ label: "Draw",
2259
+ glyph: "✎",
2260
+ action: () => setAuthorToolMode(sessionId, path, activeTool === "draw" ? "select" : "draw"),
2261
+ active: activeTool === "draw",
2262
+ annotates: true
2263
+ }] : [],
2264
+ {
2265
+ label: "Region",
2266
+ glyph: "⌗",
2267
+ action: () => setAuthorToolMode(sessionId, path, activeTool === "mark" ? "select" : "mark"),
2268
+ active: activeTool === "mark",
2269
+ annotates: true
2270
+ },
2271
+ {
2272
+ label: "Comment",
2273
+ glyph: "●",
2274
+ action: () => setAuthorToolMode(sessionId, path, activeTool === "comment" ? "select" : "comment"),
2275
+ active: activeTool === "comment",
2276
+ annotates: true
2277
+ }
2278
+ ];
2279
+ return /* @__PURE__ */ jsxs("section", {
2280
+ "data-testid": "author-tool-palette",
2281
+ className: "space-y-2",
2282
+ children: [
2283
+ /* @__PURE__ */ jsx("h3", {
2284
+ className: "sr-only",
2285
+ children: "Tools"
2286
+ }),
2287
+ /* @__PURE__ */ jsx("div", {
2288
+ className: "grid grid-cols-4 gap-1.5",
2289
+ children: tools.map((tool) => /* @__PURE__ */ jsxs(Button, {
2290
+ size: "md",
2291
+ variant: "outline",
2292
+ "aria-label": tool.label === "Region" ? "mark region" : tool.label,
2293
+ "aria-pressed": tool.active,
2294
+ onClick: tool.action,
2295
+ className: authorToolClassName(tool.active === true, tool.annotates === true),
2296
+ children: [/* @__PURE__ */ jsx("span", {
2297
+ "aria-hidden": "true",
2298
+ className: "text-lg leading-none",
2299
+ children: tool.glyph
2300
+ }), /* @__PURE__ */ jsx("span", {
2301
+ className: "text-sm",
2302
+ children: tool.label
2303
+ })]
2304
+ }, tool.label))
2305
+ }),
2306
+ /* @__PURE__ */ jsx("p", {
2307
+ className: "text-base leading-normal text-po-dim sm:text-sm",
2308
+ children: activeTool === "draw" ? "Draw a location mark, then enter feedback text. Marks never edit the source." : activeTool === "pan" ? "Drag to pan; pinch to zoom. Use Fit to reset the view." : activeTool === "mark" ? text ? "Drag over the passage you want to mark. Choose Region again to stop." : "Drag over the document to mark a region." : activeTool === "comment" ? text ? "Click a paragraph or line to comment on it. Choose Comment again to stop." : "Click the document to place a comment pin." : text ? "Select text to comment on it, or choose Region or Comment." : "Choose Region to drag an area, or Comment to place a point."
2309
+ })
2310
+ ]
2311
+ });
2312
+ }
2313
+ //#endregion
2314
+ //#region src/extensions/workspaces/sessions/(frontend)/dock/_components/AuthorPanel.tsx
2315
+ function AuthorPanel({ sessionId, submitCapture, statuses, renderSlot, renderSessionActivity }) {
2316
+ const documents = useStore(authorWorkspace.store, (state) => {
2317
+ if (sessionId === null) return [];
2318
+ const prefix = `${sessionId}\n`;
2319
+ return Object.entries(state.documents).filter(([key]) => key.startsWith(prefix)).map(([, document]) => document);
2320
+ });
2321
+ const workspace = useStore(authorWorkspace.store, (state) => sessionId === null ? void 0 : state.sessions[sessionId]);
2322
+ const grid = useStore(authorGrid.store, (state) => sessionId === null ? void 0 : state.sessions[sessionId]);
2323
+ const focused = documents.find((document) => document.path === workspace?.focusedDocument?.path);
2324
+ const focusedPath = workspace?.focusedDocument?.path;
2325
+ const requests = (workspace?.requests ?? []).filter((request) => focusedPath === void 0 || request.documentPath === focusedPath);
2326
+ const drafts = useStore(authorWorkspace.store, (state) => authorDocumentAnnotations(sessionId, focusedPath ?? "", state));
2327
+ const activity = sessionId !== null && focusedPath !== void 0 && requests.some((request) => request.status === "REQUESTED" || request.status === "CHANGING") ? renderSessionActivity?.() ?? null : null;
2328
+ return /* @__PURE__ */ jsxs("section", {
2329
+ "data-testid": "author-panel",
2330
+ className: "flex min-h-0 flex-1 flex-col overflow-hidden",
2331
+ children: [/* @__PURE__ */ jsx("div", {
2332
+ className: "min-h-0 flex-1 overflow-y-auto p-3",
2333
+ children: /* @__PURE__ */ jsxs("div", {
2334
+ className: "flex flex-col gap-3",
2335
+ children: [
2336
+ /* @__PURE__ */ jsx("div", {
2337
+ "data-testid": "author-preview-providers",
2338
+ className: "empty:hidden",
2339
+ children: renderSlot("author.preview-provider")
2340
+ }),
2341
+ sessionId !== null && workspace !== void 0 && focused !== void 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
2342
+ /* @__PURE__ */ jsx(AuthorToolPalette, {
2343
+ sessionId,
2344
+ path: focused.path,
2345
+ kind: focused.kind,
2346
+ activeTool: workspace.toolsByDocument[normalizeAuthorPath(focused.path)] ?? "select"
2347
+ }),
2348
+ /* @__PURE__ */ jsx("h2", {
2349
+ className: "border-t border-po-border-soft pt-3 text-lg font-semibold text-po-hi",
2350
+ children: "Annotations"
2351
+ }),
2352
+ /* @__PURE__ */ jsxs("p", {
2353
+ className: "text-base leading-normal text-po-dim sm:text-sm",
2354
+ children: [
2355
+ focused.kind === "markdown" || focused.kind === "text" ? "Select text or mark a passage, add feedback, then submit here." : "Drag a region or place a point, add feedback, then submit here.",
2356
+ " ",
2357
+ "Requests queue while the agent is working."
2358
+ ]
2359
+ }),
2360
+ focused.kind === "video" ? /* @__PURE__ */ jsx("p", {
2361
+ className: "text-base text-po-dim sm:text-sm",
2362
+ children: "Annotations reference video frames. They do not save changes to the source video."
2363
+ }) : null,
2364
+ autonomousVoiceGridVisible(statuses) && grid !== void 0 ? /* @__PURE__ */ jsxs("div", {
2365
+ "data-testid": "author-grid-snapshot",
2366
+ className: "rounded border border-po-red/40 bg-po-red/5 p-2 text-base text-po-dim",
2367
+ children: [/* @__PURE__ */ jsx("strong", {
2368
+ className: "text-po-red",
2369
+ children: "VOICE GRID A1–H8"
2370
+ }), /* @__PURE__ */ jsxs("p", {
2371
+ className: "mt-1 truncate",
2372
+ children: ["token ", grid.geometryToken]
2373
+ })]
2374
+ }) : null,
2375
+ /* @__PURE__ */ jsx("div", {
2376
+ className: "max-sm:hidden",
2377
+ children: /* @__PURE__ */ jsx(AuthorFeedbackControls, {
2378
+ sessionId,
2379
+ document: focused,
2380
+ drafts,
2381
+ submitCapture
2382
+ }, focused.path)
2383
+ })
2384
+ ] }) : null,
2385
+ /* @__PURE__ */ jsx("div", {
2386
+ className: focused === void 0 ? void 0 : "border-t border-po-border-soft pt-3",
2387
+ children: /* @__PURE__ */ jsx(AuthorRequestLog, { requests })
2388
+ })
2389
+ ]
2390
+ })
2391
+ }), activity === null ? null : /* @__PURE__ */ jsx("footer", {
2392
+ "data-testid": "author-session-activity",
2393
+ className: "shrink-0 border-t border-po-border bg-po-rail p-3",
2394
+ children: activity
2395
+ })]
2396
+ });
2397
+ }
2398
+ //#endregion
2399
+ //#region src/extensions/workspaces/sessions/(frontend)/dock/authoring.web.tsx
2400
+ var authoring_web_default = defineDockFace({
2401
+ id: "authoring",
2402
+ label: "Author",
2403
+ order: 30,
2404
+ panel: AuthorPanel,
2405
+ visibility: {
2406
+ subscribe(listener) {
2407
+ const subscription = authorWorkspace.store.subscribe(listener);
2408
+ return () => subscription.unsubscribe();
2409
+ },
2410
+ isVisible(sessionId) {
2411
+ return sessionId !== null && authorWorkspace.store.state.sessions[sessionId]?.focusedDocument !== void 0;
2412
+ }
2413
+ }
2414
+ });
2415
+ //#endregion
2070
2416
  //#region src/types/apiRoutes.ts
2071
2417
  /**
2072
2418
  * This package's routes, as data.
@@ -2177,20 +2523,46 @@ const api = createApiClient(apiRoutes_default, {
2177
2523
  sessionAddress: sessionApiAddress
2178
2524
  });
2179
2525
  //#endregion
2180
- //#region src/types/authorApi.ts
2181
- /** Query parameter naming the session a document request belongs to. */
2182
- const SESSION_QUERY_PARAM = "session";
2526
+ //#region src/constants/author.ts
2527
+ const AUTHOR_FILE_TAB_PREFIX = "author-file-";
2528
+ /** Names a file in the session's artifact folder rather than the working tree. Artifacts open read-only. */
2529
+ const AUTHOR_ARTIFACT_PREFIX = "artifact:";
2183
2530
  //#endregion
2184
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/authorFiles.ts
2185
- const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
2186
- "avif",
2187
- "bmp",
2188
- "gif",
2189
- "jpeg",
2190
- "jpg",
2191
- "png",
2192
- "svg",
2193
- "webp"
2531
+ //#region src/types/authorPreview.ts
2532
+ function parseAuthorOpenSourceAction(input) {
2533
+ if (typeof input !== "object" || input === null) return null;
2534
+ const candidate = input;
2535
+ return candidate.version === 1 && typeof candidate.createTab === "function" ? candidate : null;
2536
+ }
2537
+ function parseAuthorPreviewAction(input) {
2538
+ if (typeof input !== "object" || input === null) return null;
2539
+ const candidate = input;
2540
+ if (candidate.version !== 1 || typeof candidate.label !== "string" || candidate.label.trim() === "") return null;
2541
+ if (candidate.detail !== void 0 && typeof candidate.detail !== "string") return null;
2542
+ if (typeof candidate.createTab !== "function") return null;
2543
+ if (candidate.supportsSource !== void 0 && typeof candidate.supportsSource !== "function") return null;
2544
+ if (candidate.embeddedPanel !== void 0 && typeof candidate.embeddedPanel !== "function") return null;
2545
+ return candidate;
2546
+ }
2547
+ const authorPreviewActionSlot = {
2548
+ slot: "author.preview-action",
2549
+ parse: parseAuthorPreviewAction
2550
+ };
2551
+ //#endregion
2552
+ //#region src/types/authorApi.ts
2553
+ /** Query parameter naming the session a document request belongs to. */
2554
+ const SESSION_QUERY_PARAM = "session";
2555
+ //#endregion
2556
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/authorFiles.ts
2557
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
2558
+ "avif",
2559
+ "bmp",
2560
+ "gif",
2561
+ "jpeg",
2562
+ "jpg",
2563
+ "png",
2564
+ "svg",
2565
+ "webp"
2194
2566
  ]);
2195
2567
  const VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
2196
2568
  "m4v",
@@ -2318,364 +2690,120 @@ async function loadAuthorDocument(sessionId, path, signal) {
2318
2690
  path,
2319
2691
  kind,
2320
2692
  title: authorFileName(path),
2321
- structuredFormat: format,
2322
- fragments: parsed.fragments,
2323
- ...csvDialect === void 0 ? {} : { csvDialect },
2324
- originalFragments: parsed.fragments.map((fragment) => ({ ...fragment })),
2325
- ...sourceSha256 === void 0 ? {} : { sourceSha256 }
2326
- };
2327
- }
2328
- const mediaUrl = kind === "image" || kind === "video" || kind === "pdf" || kind === "opaque" ? url : void 0;
2329
- const content = kind === "text" || kind === "markdown" || kind === "story-preview" || kind === "html" ? await response.text() : void 0;
2330
- return {
2331
- path,
2332
- kind,
2333
- title: authorFileName(path),
2334
- ...content === void 0 ? {} : { content },
2335
- ...mediaUrl === void 0 ? {} : { mediaUrl },
2336
- ...sourceSha256 === void 0 ? {} : { sourceSha256 }
2337
- };
2338
- }
2339
- function operationsFor(document) {
2340
- const originals = new Map((document.originalFragments ?? []).map((fragment) => [fragment.id, fragment.text]));
2341
- return (document.fragments ?? []).filter((fragment) => !fragment.readOnly && originals.get(fragment.id) !== fragment.text).map((fragment) => ({
2342
- fragmentId: fragment.id,
2343
- replacement: fragment.text
2344
- }));
2345
- }
2346
- function decodeBase64(value) {
2347
- if (!/^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/.test(value)) throw new Error("Invalid serialized document bytes.");
2348
- const binary = atob(value);
2349
- return Uint8Array.from(binary, (character) => character.charCodeAt(0));
2350
- }
2351
- function imageMimeType(path) {
2352
- const extension = path.split(".").at(-1)?.toLowerCase();
2353
- if (extension === "png") return "image/png";
2354
- if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
2355
- if (extension === "webp") return "image/webp";
2356
- }
2357
- async function croppedImageBytes(documentInput, signal) {
2358
- const { crop, mediaUrl } = documentInput;
2359
- if (crop === void 0 || mediaUrl === void 0) throw new Error("This Author image has no crop source.");
2360
- const mimeType = imageMimeType(documentInput.path);
2361
- if (mimeType === void 0) throw new Error("This image format cannot preserve its encoding after crop.");
2362
- const response = await browserTransport.fetch(mediaUrl, {
2363
- signal,
2364
- cache: "no-store"
2365
- });
2366
- if (!response.ok) throw new Error(`Author could not read the image before saving (${response.status})`);
2367
- const bitmap = await createImageBitmap(await response.blob());
2368
- try {
2369
- const sourceX = Math.round(crop.x * bitmap.width);
2370
- const sourceY = Math.round(crop.y * bitmap.height);
2371
- const sourceWidth = Math.max(1, Math.round(crop.width * bitmap.width));
2372
- const sourceHeight = Math.max(1, Math.round(crop.height * bitmap.height));
2373
- const canvas = window.document.createElement("canvas");
2374
- canvas.width = sourceWidth;
2375
- canvas.height = sourceHeight;
2376
- const context = canvas.getContext("2d");
2377
- if (context === null) throw new Error("Canvas is unavailable for image crop.");
2378
- context.drawImage(bitmap, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
2379
- const blob = await new Promise((resolve) => canvas.toBlob(resolve, mimeType));
2380
- if (blob === null || blob.type !== mimeType) throw new Error("The browser cannot encode this cropped image format.");
2381
- return blob;
2382
- } finally {
2383
- bitmap.close();
2384
- }
2385
- }
2386
- async function bytesToSave(sessionId, document, signal) {
2387
- if (document.kind === "text" || document.kind === "markdown" || document.kind === "story-preview" || document.kind === "html") return document.content ?? "";
2388
- if (document.kind === "image" && document.crop !== void 0) return await croppedImageBytes(document, signal);
2389
- if (document.structuredFormat === void 0) throw new Error("This Author view cannot be saved.");
2390
- const operations = operationsFor(document);
2391
- const request = {
2392
- path: document.path,
2393
- format: document.structuredFormat,
2394
- operations,
2395
- ...document.csvDialect === void 0 ? {} : { csvDialect: document.csvDialect }
2396
- };
2397
- const preflight = await documentRequest(api.session(sessionId).documentsPreflight, sessionId, request, signal);
2398
- if (preflight.sourceDigest !== void 0 && preflight.sourceDigest !== document.sourceSha256) throw new Error("Document source changed before saving. Reopen the document.");
2399
- if (!preflight.accepted) throw new Error(preflight.issues.map((issue) => issue.message).join("\n") || "Author preflight rejected the edit.");
2400
- const serialized = await documentRequest(api.session(sessionId).documentsSerialize, sessionId, {
2401
- ...request,
2402
- preflightDigest: preflight.digest
2403
- }, signal);
2404
- if (serialized.encoding !== "base64") throw new Error("Author serialize returned an unsupported encoding.");
2405
- const bytes = decodeBase64(serialized.bytes);
2406
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2407
- }
2408
- async function saveAuthorDocument(sessionId, document, signal) {
2409
- if (document.path.startsWith("artifact:")) throw new Error("Artifacts are read-only.");
2410
- if (document.sourceSha256 === void 0) throw new Error("This Author view has no source digest.");
2411
- const response = await browserTransport.fetch(authorSessionFileUrl(sessionId, document.path), {
2412
- method: "PUT",
2413
- headers: {
2414
- "Content-Type": "application/octet-stream",
2415
- "X-Expected-SHA256": document.sourceSha256
2416
- },
2417
- body: await bytesToSave(sessionId, document, signal),
2418
- signal
2419
- });
2420
- if (!response.ok) throw new Error(`Author save failed (${response.status})`);
2421
- const sha256 = response.headers.get("X-File-SHA256");
2422
- if (sha256 === null || !/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Author save returned an invalid digest.");
2423
- return sha256;
2424
- }
2425
- function csvDialectFromMetadata(metadata) {
2426
- if (metadata === void 0) return void 0;
2427
- const { delimiter, quote, recordDelimiter } = metadata;
2428
- if (delimiter !== "," && delimiter !== ";" && delimiter !== " " && delimiter !== "|" || quote !== "\"" && quote !== "'" || recordDelimiter !== "\n" && recordDelimiter !== "\r\n") throw new Error("Author document API returned an invalid CSV dialect.");
2429
- return {
2430
- delimiter,
2431
- quote,
2432
- recordDelimiter
2433
- };
2434
- }
2435
- //#endregion
2436
- //#region src/extensions/workspaces/sessions/(frontend)/dock/_components/AuthorToolPalette.tsx
2437
- /** One tool tile. An active annotation tool is the person's mark (rose); an active view tool is Po's jade. */
2438
- function authorToolClassName(active, annotates) {
2439
- return `h-auto min-h-11 min-w-11 flex-col gap-1 rounded-md px-1 py-2 text-base [@media(pointer:fine)]:min-h-8 ${!active ? "border-po-border-soft bg-po-panel text-po-dim" : annotates ? "border-po-edge-magenta bg-po-tint-magenta font-semibold text-po-magenta hover:text-po-magenta" : "border-po-edge-green bg-po-tint-green font-semibold text-po-green hover:text-po-green"}`;
2440
- }
2441
- function AuthorToolPalette({ sessionId, path, kind, activeTool }) {
2442
- const [error, setError] = useState();
2443
- const format = (style) => {
2444
- const selected = authorDocumentAnnotations(sessionId, path).candidate;
2445
- const document = selected && authorDocument(sessionId, selected.documentPath);
2446
- if (!selected || !document || selected.anchor.kind !== "text-range" || selected.revision !== document.version || selected.sourceSha256 !== document.sourceSha256) {
2447
- setError("Select text in the document first.");
2448
- return;
2449
- }
2450
- const { startOffset, endOffset } = selected.anchor;
2451
- const content = document.content ?? "";
2452
- const text = content.slice(startOffset, endOffset);
2453
- const replacement = style === "Bold" ? `**${text}**` : style === "Link" ? `[${text}](https://)` : text.split("\n").map((line) => `${style === "Heading" ? "## " : "- "}${line}`).join("\n");
2454
- reviseAuthorDocument(sessionId, document.path, content.slice(0, startOffset) + replacement + content.slice(endOffset));
2455
- setAuthorRegionCandidate(sessionId, path, void 0);
2456
- setError(void 0);
2457
- };
2458
- const tools = [
2459
- ...[
2460
- "image",
2461
- "pdf",
2462
- "video",
2463
- "story-preview"
2464
- ].includes(kind) ? [{
2465
- label: "Pan",
2466
- glyph: "✋",
2467
- action: () => setAuthorToolMode(sessionId, path, activeTool === "pan" ? "select" : "pan"),
2468
- active: activeTool === "pan",
2469
- annotates: false
2470
- }] : [],
2471
- ...[
2472
- "image",
2473
- "pdf",
2474
- "video",
2475
- "story-preview",
2476
- "html"
2477
- ].includes(kind) ? [{
2478
- label: "Draw",
2479
- glyph: "✎",
2480
- action: () => setAuthorToolMode(sessionId, path, activeTool === "draw" ? "select" : "draw"),
2481
- active: activeTool === "draw",
2482
- annotates: true
2483
- }] : [],
2484
- {
2485
- label: "Region",
2486
- glyph: "⌗",
2487
- action: () => setAuthorToolMode(sessionId, path, activeTool === "mark" ? "select" : "mark"),
2488
- active: activeTool === "mark",
2489
- annotates: true
2490
- },
2491
- ...kind === "markdown" && !isMermaidPath(path) ? [
2492
- {
2493
- label: "Bold",
2494
- glyph: "B",
2495
- action: () => format("Bold")
2496
- },
2497
- {
2498
- label: "Heading",
2499
- glyph: "H",
2500
- action: () => format("Heading")
2501
- },
2502
- {
2503
- label: "Link",
2504
- glyph: "↗",
2505
- action: () => format("Link")
2506
- },
2507
- {
2508
- label: "List",
2509
- glyph: "≡",
2510
- action: () => format("List")
2511
- }
2512
- ] : [],
2513
- {
2514
- label: "Comment",
2515
- glyph: "●",
2516
- action: () => setAuthorToolMode(sessionId, path, activeTool === "comment" ? "select" : "comment"),
2517
- active: activeTool === "comment",
2518
- annotates: true
2519
- }
2520
- ];
2521
- return /* @__PURE__ */ jsxs("section", {
2522
- "data-testid": "author-tool-palette",
2523
- className: "space-y-2",
2524
- children: [
2525
- /* @__PURE__ */ jsx("h3", {
2526
- className: "sr-only",
2527
- children: "Tools"
2528
- }),
2529
- /* @__PURE__ */ jsx("div", {
2530
- className: "grid grid-cols-4 gap-1.5",
2531
- children: tools.map((tool) => /* @__PURE__ */ jsxs(Button, {
2532
- size: "md",
2533
- variant: "outline",
2534
- "aria-label": tool.label === "Region" ? "mark region" : tool.label,
2535
- "aria-pressed": tool.active,
2536
- onClick: tool.action,
2537
- className: authorToolClassName(tool.active === true, tool.annotates === true),
2538
- children: [/* @__PURE__ */ jsx("span", {
2539
- "aria-hidden": "true",
2540
- className: "text-lg leading-none",
2541
- children: tool.glyph
2542
- }), /* @__PURE__ */ jsx("span", {
2543
- className: "text-sm",
2544
- children: tool.label
2545
- })]
2546
- }, tool.label))
2547
- }),
2548
- /* @__PURE__ */ jsx("p", {
2549
- className: "text-base leading-normal text-po-dim sm:text-sm",
2550
- children: activeTool === "draw" ? "Draw a location mark, then enter feedback text. Marks never edit the source." : activeTool === "pan" ? "Drag to pan; pinch to zoom. Use Fit to reset the view." : activeTool === "mark" ? "Drag over the document to mark a region." : activeTool === "comment" ? "Click the document to place a comment pin." : "Choose Region to drag an area, or Comment to place a point."
2551
- }),
2552
- error ? /* @__PURE__ */ jsx("output", {
2553
- className: "block text-base text-po-red",
2554
- children: error
2555
- }) : null
2556
- ]
2557
- });
2558
- }
2559
- //#endregion
2560
- //#region src/extensions/workspaces/sessions/(frontend)/dock/_components/AuthorPanel.tsx
2561
- function AuthorPanel({ sessionId, submitCapture, statuses, renderSlot, renderSessionActivity }) {
2562
- const documents = useStore(authorWorkspace.store, (state) => {
2563
- if (sessionId === null) return [];
2564
- const prefix = `${sessionId}\n`;
2565
- return Object.entries(state.documents).filter(([key]) => key.startsWith(prefix)).map(([, document]) => document);
2566
- });
2567
- const workspace = useStore(authorWorkspace.store, (state) => sessionId === null ? void 0 : state.sessions[sessionId]);
2568
- const grid = useStore(authorGrid.store, (state) => sessionId === null ? void 0 : state.sessions[sessionId]);
2569
- const focused = documents.find((document) => document.path === workspace?.focusedDocument?.path);
2570
- const focusedPath = workspace?.focusedDocument?.path;
2571
- const requests = (workspace?.requests ?? []).filter((request) => focusedPath === void 0 || request.documentPath === focusedPath);
2572
- const drafts = useStore(authorWorkspace.store, (state) => authorDocumentAnnotations(sessionId, focusedPath ?? "", state));
2573
- const activity = sessionId !== null && focusedPath !== void 0 && requests.some((request) => request.status === "REQUESTED" || request.status === "CHANGING") ? renderSessionActivity?.() ?? null : null;
2574
- return /* @__PURE__ */ jsxs("section", {
2575
- "data-testid": "author-panel",
2576
- className: "flex min-h-0 flex-1 flex-col overflow-hidden",
2577
- children: [/* @__PURE__ */ jsx("div", {
2578
- className: "min-h-0 flex-1 overflow-y-auto p-3",
2579
- children: /* @__PURE__ */ jsxs("div", {
2580
- className: "flex flex-col gap-3",
2581
- children: [
2582
- /* @__PURE__ */ jsx("div", {
2583
- "data-testid": "author-preview-providers",
2584
- className: "empty:hidden",
2585
- children: renderSlot("author.preview-provider")
2586
- }),
2587
- sessionId !== null && workspace !== void 0 && focused !== void 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
2588
- /* @__PURE__ */ jsx(AuthorToolPalette, {
2589
- sessionId,
2590
- path: focused.path,
2591
- kind: focused.kind,
2592
- activeTool: workspace.toolsByDocument[normalizeAuthorPath(focused.path)] ?? "select"
2593
- }),
2594
- /* @__PURE__ */ jsx("h2", {
2595
- className: "border-t border-po-border-soft pt-3 text-lg font-semibold text-po-hi",
2596
- children: "Annotations"
2597
- }),
2598
- /* @__PURE__ */ jsx("p", {
2599
- className: "text-base leading-normal text-po-dim sm:text-sm",
2600
- children: "Drag a region or place a point, add feedback, then submit here. Requests queue while the agent is working."
2601
- }),
2602
- focused.kind === "video" ? /* @__PURE__ */ jsx("p", {
2603
- className: "text-base text-po-dim sm:text-sm",
2604
- children: "Annotations reference video frames. They do not save changes to the source video."
2605
- }) : null,
2606
- autonomousVoiceGridVisible(statuses) && grid !== void 0 ? /* @__PURE__ */ jsxs("div", {
2607
- "data-testid": "author-grid-snapshot",
2608
- className: "rounded border border-po-red/40 bg-po-red/5 p-2 text-base text-po-dim",
2609
- children: [/* @__PURE__ */ jsx("strong", {
2610
- className: "text-po-red",
2611
- children: "VOICE GRID A1–H8"
2612
- }), /* @__PURE__ */ jsxs("p", {
2613
- className: "mt-1 truncate",
2614
- children: ["token ", grid.geometryToken]
2615
- })]
2616
- }) : null,
2617
- /* @__PURE__ */ jsx("div", {
2618
- className: "max-sm:hidden",
2619
- children: /* @__PURE__ */ jsx(AuthorFeedbackControls, {
2620
- sessionId,
2621
- document: focused,
2622
- drafts,
2623
- submitCapture
2624
- }, focused.path)
2625
- })
2626
- ] }) : null,
2627
- /* @__PURE__ */ jsx("div", {
2628
- className: focused === void 0 ? void 0 : "border-t border-po-border-soft pt-3",
2629
- children: /* @__PURE__ */ jsx(AuthorRequestLog, { requests })
2630
- })
2631
- ]
2632
- })
2633
- }), activity === null ? null : /* @__PURE__ */ jsx("footer", {
2634
- "data-testid": "author-session-activity",
2635
- className: "shrink-0 border-t border-po-border bg-po-rail p-3",
2636
- children: activity
2637
- })]
2693
+ structuredFormat: format,
2694
+ fragments: parsed.fragments,
2695
+ ...csvDialect === void 0 ? {} : { csvDialect },
2696
+ originalFragments: parsed.fragments.map((fragment) => ({ ...fragment })),
2697
+ ...sourceSha256 === void 0 ? {} : { sourceSha256 }
2698
+ };
2699
+ }
2700
+ const mediaUrl = kind === "image" || kind === "video" || kind === "pdf" || kind === "opaque" ? url : void 0;
2701
+ const content = kind === "text" || kind === "markdown" || kind === "story-preview" || kind === "html" ? await response.text() : void 0;
2702
+ return {
2703
+ path,
2704
+ kind,
2705
+ title: authorFileName(path),
2706
+ ...content === void 0 ? {} : { content },
2707
+ ...mediaUrl === void 0 ? {} : { mediaUrl },
2708
+ ...sourceSha256 === void 0 ? {} : { sourceSha256 }
2709
+ };
2710
+ }
2711
+ function operationsFor(document) {
2712
+ const originals = new Map((document.originalFragments ?? []).map((fragment) => [fragment.id, fragment.text]));
2713
+ return (document.fragments ?? []).filter((fragment) => !fragment.readOnly && originals.get(fragment.id) !== fragment.text).map((fragment) => ({
2714
+ fragmentId: fragment.id,
2715
+ replacement: fragment.text
2716
+ }));
2717
+ }
2718
+ function decodeBase64(value) {
2719
+ if (!/^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/.test(value)) throw new Error("Invalid serialized document bytes.");
2720
+ const binary = atob(value);
2721
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
2722
+ }
2723
+ function imageMimeType(path) {
2724
+ const extension = path.split(".").at(-1)?.toLowerCase();
2725
+ if (extension === "png") return "image/png";
2726
+ if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
2727
+ if (extension === "webp") return "image/webp";
2728
+ }
2729
+ async function croppedImageBytes(documentInput, signal) {
2730
+ const { crop, mediaUrl } = documentInput;
2731
+ if (crop === void 0 || mediaUrl === void 0) throw new Error("This Author image has no crop source.");
2732
+ const mimeType = imageMimeType(documentInput.path);
2733
+ if (mimeType === void 0) throw new Error("This image format cannot preserve its encoding after crop.");
2734
+ const response = await browserTransport.fetch(mediaUrl, {
2735
+ signal,
2736
+ cache: "no-store"
2638
2737
  });
2738
+ if (!response.ok) throw new Error(`Author could not read the image before saving (${response.status})`);
2739
+ const bitmap = await createImageBitmap(await response.blob());
2740
+ try {
2741
+ const sourceX = Math.round(crop.x * bitmap.width);
2742
+ const sourceY = Math.round(crop.y * bitmap.height);
2743
+ const sourceWidth = Math.max(1, Math.round(crop.width * bitmap.width));
2744
+ const sourceHeight = Math.max(1, Math.round(crop.height * bitmap.height));
2745
+ const canvas = window.document.createElement("canvas");
2746
+ canvas.width = sourceWidth;
2747
+ canvas.height = sourceHeight;
2748
+ const context = canvas.getContext("2d");
2749
+ if (context === null) throw new Error("Canvas is unavailable for image crop.");
2750
+ context.drawImage(bitmap, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
2751
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, mimeType));
2752
+ if (blob === null || blob.type !== mimeType) throw new Error("The browser cannot encode this cropped image format.");
2753
+ return blob;
2754
+ } finally {
2755
+ bitmap.close();
2756
+ }
2639
2757
  }
2640
- //#endregion
2641
- //#region src/extensions/workspaces/sessions/(frontend)/dock/authoring.web.tsx
2642
- var authoring_web_default = defineDockFace({
2643
- id: "authoring",
2644
- label: "Author",
2645
- order: 30,
2646
- autoSelect: ({ tabId }) => tabId?.startsWith(AUTHOR_FILE_TAB_PREFIX) === true,
2647
- panel: AuthorPanel,
2648
- visibility: {
2649
- subscribe(listener) {
2650
- const subscription = authorWorkspace.store.subscribe(listener);
2651
- return () => subscription.unsubscribe();
2758
+ async function bytesToSave(sessionId, document, signal) {
2759
+ if (document.kind === "text" || document.kind === "markdown" || document.kind === "story-preview" || document.kind === "html") return document.content ?? "";
2760
+ if (document.kind === "image" && document.crop !== void 0) return await croppedImageBytes(document, signal);
2761
+ if (document.structuredFormat === void 0) throw new Error("This Author view cannot be saved.");
2762
+ const operations = operationsFor(document);
2763
+ const request = {
2764
+ path: document.path,
2765
+ format: document.structuredFormat,
2766
+ operations,
2767
+ ...document.csvDialect === void 0 ? {} : { csvDialect: document.csvDialect }
2768
+ };
2769
+ const preflight = await documentRequest(api.session(sessionId).documentsPreflight, sessionId, request, signal);
2770
+ if (preflight.sourceDigest !== void 0 && preflight.sourceDigest !== document.sourceSha256) throw new Error("Document source changed before saving. Reopen the document.");
2771
+ if (!preflight.accepted) throw new Error(preflight.issues.map((issue) => issue.message).join("\n") || "Author preflight rejected the edit.");
2772
+ const serialized = await documentRequest(api.session(sessionId).documentsSerialize, sessionId, {
2773
+ ...request,
2774
+ preflightDigest: preflight.digest
2775
+ }, signal);
2776
+ if (serialized.encoding !== "base64") throw new Error("Author serialize returned an unsupported encoding.");
2777
+ const bytes = decodeBase64(serialized.bytes);
2778
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2779
+ }
2780
+ async function saveAuthorDocument(sessionId, document, signal) {
2781
+ if (document.path.startsWith("artifact:")) throw new Error("Artifacts are read-only.");
2782
+ if (document.sourceSha256 === void 0) throw new Error("This Author view has no source digest.");
2783
+ const response = await browserTransport.fetch(authorSessionFileUrl(sessionId, document.path), {
2784
+ method: "PUT",
2785
+ headers: {
2786
+ "Content-Type": "application/octet-stream",
2787
+ "X-Expected-SHA256": document.sourceSha256
2652
2788
  },
2653
- isVisible(sessionId) {
2654
- return sessionId !== null && authorWorkspace.store.state.sessions[sessionId]?.focusedDocument !== void 0;
2655
- }
2656
- }
2657
- });
2658
- //#endregion
2659
- //#region src/types/authorPreview.ts
2660
- function parseAuthorOpenSourceAction(input) {
2661
- if (typeof input !== "object" || input === null) return null;
2662
- const candidate = input;
2663
- return candidate.version === 1 && typeof candidate.createTab === "function" ? candidate : null;
2789
+ body: await bytesToSave(sessionId, document, signal),
2790
+ signal
2791
+ });
2792
+ if (!response.ok) throw new Error(`Author save failed (${response.status})`);
2793
+ const sha256 = response.headers.get("X-File-SHA256");
2794
+ if (sha256 === null || !/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Author save returned an invalid digest.");
2795
+ return sha256;
2664
2796
  }
2665
- function parseAuthorPreviewAction(input) {
2666
- if (typeof input !== "object" || input === null) return null;
2667
- const candidate = input;
2668
- if (candidate.version !== 1 || typeof candidate.label !== "string" || candidate.label.trim() === "") return null;
2669
- if (candidate.detail !== void 0 && typeof candidate.detail !== "string") return null;
2670
- if (typeof candidate.createTab !== "function") return null;
2671
- if (candidate.supportsSource !== void 0 && typeof candidate.supportsSource !== "function") return null;
2672
- if (candidate.embeddedPanel !== void 0 && typeof candidate.embeddedPanel !== "function") return null;
2673
- return candidate;
2797
+ function csvDialectFromMetadata(metadata) {
2798
+ if (metadata === void 0) return void 0;
2799
+ const { delimiter, quote, recordDelimiter } = metadata;
2800
+ if (delimiter !== "," && delimiter !== ";" && delimiter !== " " && delimiter !== "|" || quote !== "\"" && quote !== "'" || recordDelimiter !== "\n" && recordDelimiter !== "\r\n") throw new Error("Author document API returned an invalid CSV dialect.");
2801
+ return {
2802
+ delimiter,
2803
+ quote,
2804
+ recordDelimiter
2805
+ };
2674
2806
  }
2675
- const authorPreviewActionSlot = {
2676
- slot: "author.preview-action",
2677
- parse: parseAuthorPreviewAction
2678
- };
2679
2807
  //#endregion
2680
2808
  //#region src/extensions/workspaces/sessions/(frontend)/_lib/authorGridTools.ts
2681
2809
  const stringProperty = {
@@ -2908,6 +3036,7 @@ function mutationTool(sessionId, path, kind) {
2908
3036
  revision: document.version + 1
2909
3037
  };
2910
3038
  } else throw new Error("UNSUPPORTED_REGION: PDF, video and rendered HTML regions are capture-only.");
3039
+ removeAuthorRegion(sessionId, path, region.id);
2911
3040
  if (request !== void 0) {
2912
3041
  const pendingRegions = rebasePendingRegions(pending, region, result, nextContent);
2913
3042
  updateAuthorRequest(sessionId, request.id, (current) => ({
@@ -4589,9 +4718,118 @@ function AuthorStructuredView({ sessionId, document, displayedRegions }) {
4589
4718
  }
4590
4719
  //#endregion
4591
4720
  //#region src/extensions/workspaces/sessions/(frontend)/_components/AuthorTextView.tsx
4592
- function AuthorTextView({ sessionId, document, preview, displayedRegions, renderMediaReview }) {
4721
+ const FORMATS = [
4722
+ {
4723
+ format: "bold",
4724
+ label: "Bold",
4725
+ glyph: "B"
4726
+ },
4727
+ {
4728
+ format: "heading",
4729
+ label: "Heading",
4730
+ glyph: "H"
4731
+ },
4732
+ {
4733
+ format: "link",
4734
+ label: "Link",
4735
+ glyph: "↗"
4736
+ },
4737
+ {
4738
+ format: "list",
4739
+ label: "List",
4740
+ glyph: "≡"
4741
+ }
4742
+ ];
4743
+ /** The blocks Comment marks in the reading view: one paragraph, item, heading, quote or code block. */
4744
+ const COMMENT_BLOCKS = "p,li,h1,h2,h3,h4,h5,h6,pre,blockquote,td,th";
4745
+ /** The CSS highlight that paints unsent notes on the reading view. */
4746
+ const NOTE_HIGHLIGHT = "po-author-note";
4747
+ /** The label shown for the passage being marked, before it has feedback. */
4748
+ const CANDIDATE_LABEL = "•";
4749
+ /** Rendered text in reading order, minus diagrams and controls that are not document text. */
4750
+ function renderedRuns(article) {
4751
+ const walker = article.ownerDocument.createTreeWalker(article, NodeFilter.SHOW_TEXT, { acceptNode: (node) => node.parentElement?.closest("svg,button,[aria-hidden=\"true\"]") ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT });
4752
+ const runs = [];
4753
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) runs.push(node);
4754
+ return runs;
4755
+ }
4756
+ /** Maps a range over the rendered page back to the Markdown source it came from. */
4757
+ function sourceRange(article, source, range) {
4758
+ const runs = renderedRuns(article);
4759
+ const offsets = locateRenderedRuns(source, runs.map((run) => run.data));
4760
+ let from;
4761
+ let to;
4762
+ runs.forEach((run, index) => {
4763
+ const at = offsets[index];
4764
+ if (at === void 0 || !range.intersectsNode(run)) return;
4765
+ from ??= at + (run === range.startContainer ? range.startOffset : 0);
4766
+ to = Math.min(source.length, at + (run === range.endContainer ? range.endOffset : run.data.length));
4767
+ });
4768
+ return from === void 0 || to === void 0 || to <= from ? void 0 : {
4769
+ from,
4770
+ to
4771
+ };
4772
+ }
4773
+ /** The rendered text a source range covers: the way back from a note to the page. */
4774
+ function renderedRange(article, source, from, to) {
4775
+ const runs = renderedRuns(article);
4776
+ const offsets = locateRenderedRuns(source, runs.map((run) => run.data));
4777
+ let first;
4778
+ let last;
4779
+ runs.forEach((run, index) => {
4780
+ const at = offsets[index];
4781
+ if (at === void 0 || at + run.data.length <= from || at >= to) return;
4782
+ first ??= {
4783
+ run,
4784
+ offset: Math.max(0, from - at)
4785
+ };
4786
+ last = {
4787
+ run,
4788
+ offset: Math.min(run.data.length, to - at)
4789
+ };
4790
+ });
4791
+ if (first === void 0 || last === void 0) return void 0;
4792
+ const range = article.ownerDocument.createRange();
4793
+ range.setStart(first.run, first.offset);
4794
+ range.setEnd(last.run, last.offset);
4795
+ return range;
4796
+ }
4797
+ /** The rendered text a dragged box covers, run by run. */
4798
+ function rangeInBox(article, box) {
4799
+ const probe = article.ownerDocument.createRange();
4800
+ const covered = renderedRuns(article).filter((run) => {
4801
+ probe.selectNodeContents(run);
4802
+ return [...probe.getClientRects()].some((rect) => rect.right > box.left && rect.left < box.right && rect.bottom > box.top && rect.top < box.bottom);
4803
+ });
4804
+ const first = covered[0];
4805
+ const last = covered.at(-1);
4806
+ if (first === void 0 || last === void 0) return void 0;
4807
+ probe.setStart(first, 0);
4808
+ probe.setEnd(last, last.data.length);
4809
+ return probe;
4810
+ }
4811
+ function AuthorTextView({ sessionId, document, preview, activeTool = "select", displayedRegions, candidate, renderMediaReview }) {
4593
4812
  const host = useRef(null);
4594
4813
  const editor = useRef(null);
4814
+ const article = useRef(null);
4815
+ const selected = useRef({
4816
+ from: 0,
4817
+ to: 0
4818
+ });
4819
+ const [box, setBox] = useState();
4820
+ const [notice, setNotice] = useState();
4821
+ const [badges, setBadges] = useState([]);
4822
+ const formatting = !preview && document.kind === "markdown" && !isMermaidPath(document.path);
4823
+ const marks = [...displayedRegions.flatMap(({ ordinal, region }) => region.anchor.kind === "text-range" ? [{
4824
+ label: String(ordinal),
4825
+ from: region.anchor.startOffset,
4826
+ to: region.anchor.endOffset
4827
+ }] : []), ...candidate?.anchor.kind === "text-range" ? [{
4828
+ label: CANDIDATE_LABEL,
4829
+ from: candidate.anchor.startOffset,
4830
+ to: candidate.anchor.endOffset
4831
+ }] : []];
4832
+ const marksKey = JSON.stringify(marks);
4595
4833
  useEffect(() => registerAuthorGridResolver(sessionId, (cell, geometry) => {
4596
4834
  if (preview || editor.current === null) return void 0;
4597
4835
  const { originX = 0, originY = 0, width, height } = geometry.viewport;
@@ -4614,12 +4852,44 @@ function AuthorTextView({ sessionId, document, preview, displayedRegions, render
4614
4852
  };
4615
4853
  }), [preview, sessionId]);
4616
4854
  useEffect(() => {
4617
- editor.current?.setMarkedRanges(displayedRegions.flatMap(({ ordinal, region }) => region.anchor.kind === "text-range" ? [{
4618
- from: region.anchor.startOffset,
4619
- to: region.anchor.endOffset,
4620
- label: String(ordinal)
4621
- }] : []));
4622
- }, [displayedRegions]);
4855
+ editor.current?.setMarkedRanges(JSON.parse(marksKey));
4856
+ }, [marksKey]);
4857
+ useEffect(() => {
4858
+ const root = article.current;
4859
+ const frame = root?.parentElement;
4860
+ if (!preview || !root || !frame) return void 0;
4861
+ const paint = () => {
4862
+ const source = document.content ?? "";
4863
+ const placed = JSON.parse(marksKey).flatMap((mark) => {
4864
+ const range = renderedRange(root, source, mark.from, mark.to);
4865
+ return range === void 0 ? [] : [{
4866
+ label: mark.label,
4867
+ range
4868
+ }];
4869
+ });
4870
+ if (typeof CSS !== "undefined" && "highlights" in CSS) CSS.highlights.set(NOTE_HIGHLIGHT, new Highlight(...placed.map(({ range }) => range)));
4871
+ const origin = frame.getBoundingClientRect();
4872
+ setBadges(placed.map(({ label, range }) => {
4873
+ const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
4874
+ return {
4875
+ label,
4876
+ left: rect.left - origin.left,
4877
+ top: rect.top - origin.top
4878
+ };
4879
+ }));
4880
+ };
4881
+ paint();
4882
+ const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(paint);
4883
+ observer?.observe(root);
4884
+ return () => {
4885
+ observer?.disconnect();
4886
+ if (typeof CSS !== "undefined" && "highlights" in CSS) CSS.highlights.delete(NOTE_HIGHLIGHT);
4887
+ };
4888
+ }, [
4889
+ preview,
4890
+ marksKey,
4891
+ document.content
4892
+ ]);
4623
4893
  const lines = (document.content ?? "").split("\n").length;
4624
4894
  const loadImage = useCallback(async (imagePath) => {
4625
4895
  const path = authorMarkdownImagePath(document.path, imagePath);
@@ -4628,69 +4898,296 @@ function AuthorTextView({ sessionId, document, preview, displayedRegions, render
4628
4898
  path
4629
4899
  };
4630
4900
  }, [sessionId, document.path]);
4901
+ const format = (style) => {
4902
+ const current = authorDocument(sessionId, document.path);
4903
+ if (!current || editor.current === null) return;
4904
+ const { from, to } = selected.current;
4905
+ const edit = authorMarkdownFormat(current.content ?? "", from, to, style);
4906
+ editor.current.applyEdits([{
4907
+ from: edit.from,
4908
+ to: edit.to,
4909
+ insert: edit.insert
4910
+ }]);
4911
+ editor.current.revealAndSelect(edit.select);
4912
+ editor.current.focus();
4913
+ };
4914
+ /** Makes a source range the annotation candidate the rail asks feedback for. */
4915
+ const markSource = (found, mode, missing) => {
4916
+ const current = authorDocument(sessionId, document.path);
4917
+ const bounds = host.current?.getBoundingClientRect();
4918
+ if (!current || !bounds) return;
4919
+ if (found === void 0 || found.to <= found.from) {
4920
+ setNotice(missing);
4921
+ return;
4922
+ }
4923
+ const source = current.content ?? "";
4924
+ setNotice(void 0);
4925
+ setAuthorRegionCandidate(sessionId, document.path, {
4926
+ documentPath: current.path,
4927
+ revision: current.version,
4928
+ sourceSha256: current.sourceSha256,
4929
+ mode,
4930
+ quote: source.slice(found.from, found.to),
4931
+ anchor: {
4932
+ kind: "text-range",
4933
+ startOffset: found.from,
4934
+ endOffset: found.to,
4935
+ startLine: authorSourceLine(source, found.from),
4936
+ endLine: authorSourceLine(source, found.to)
4937
+ },
4938
+ viewport: {
4939
+ width: bounds.width,
4940
+ height: bounds.height
4941
+ },
4942
+ createdAt: Date.now()
4943
+ });
4944
+ };
4945
+ const markRendered = (range, mode) => {
4946
+ if (article.current === null) return;
4947
+ markSource(sourceRange(article.current, authorDocument(sessionId, document.path)?.content ?? "", range), mode, "That part of the page has no source text to mark. Press Edit to select it in the source.");
4948
+ };
4949
+ const markSelection = () => {
4950
+ if (activeTool !== "select" || article.current === null) return;
4951
+ const selection = window.getSelection();
4952
+ if (selection === null || selection.isCollapsed || selection.rangeCount === 0) return;
4953
+ const range = selection.getRangeAt(0);
4954
+ if (article.current.contains(range.commonAncestorContainer)) markRendered(range, "region");
4955
+ };
4956
+ const annotating = activeTool === "mark" || activeTool === "comment";
4957
+ const drawBox = (event, start) => {
4958
+ const bounds = event.currentTarget.getBoundingClientRect();
4959
+ const x = event.clientX - bounds.left;
4960
+ const y = event.clientY - bounds.top;
4961
+ return {
4962
+ ...start,
4963
+ left: Math.min(start.x, x),
4964
+ top: Math.min(start.y, y),
4965
+ width: Math.abs(x - start.x),
4966
+ height: Math.abs(y - start.y)
4967
+ };
4968
+ };
4969
+ /** Region and Comment over the source editor: a dragged stretch of text, or the clicked line. */
4970
+ const finishSourceAnnotation = (event, started) => {
4971
+ const source = authorDocument(sessionId, document.path)?.content ?? "";
4972
+ if (editor.current === null) return;
4973
+ if (activeTool === "comment") {
4974
+ const hit = editor.current.resolveViewportRegion({
4975
+ left: event.clientX,
4976
+ top: event.clientY,
4977
+ right: event.clientX,
4978
+ bottom: event.clientY
4979
+ });
4980
+ const lineStart = hit === null ? 0 : source.lastIndexOf("\n", hit.from - 1) + 1;
4981
+ const lineEnd = hit === null ? -1 : source.indexOf("\n", hit.from);
4982
+ markSource(hit === null ? void 0 : {
4983
+ from: lineStart,
4984
+ to: lineEnd === -1 ? source.length : lineEnd
4985
+ }, "point", "Click a line with text to comment on it.");
4986
+ return;
4987
+ }
4988
+ const drawn = drawBox(event, started);
4989
+ if (drawn.width < 4 && drawn.height < 4) return;
4990
+ const overlay = event.currentTarget.getBoundingClientRect();
4991
+ const hit = editor.current.resolveViewportRegion({
4992
+ left: overlay.left + drawn.left,
4993
+ top: overlay.top + drawn.top,
4994
+ right: overlay.left + drawn.left + drawn.width,
4995
+ bottom: overlay.top + drawn.top + drawn.height
4996
+ });
4997
+ markSource(hit === null ? void 0 : {
4998
+ from: hit.from,
4999
+ to: hit.to
5000
+ }, "region", "Drag over some text to mark it.");
5001
+ };
5002
+ const finishAnnotation = (event) => {
5003
+ const started = box;
5004
+ setBox(void 0);
5005
+ if (started === void 0 || started.pointerId !== event.pointerId) return;
5006
+ if (!preview) {
5007
+ finishSourceAnnotation(event, started);
5008
+ return;
5009
+ }
5010
+ if (article.current === null) return;
5011
+ if (activeTool === "comment") {
5012
+ const block = window.document.elementsFromPoint(event.clientX, event.clientY).find((element) => article.current?.contains(element) && element.closest(COMMENT_BLOCKS) !== null)?.closest(COMMENT_BLOCKS);
5013
+ if (!block) return;
5014
+ const range = window.document.createRange();
5015
+ range.selectNodeContents(block);
5016
+ markRendered(range, "point");
5017
+ return;
5018
+ }
5019
+ const drawn = drawBox(event, started);
5020
+ if (drawn.width < 4 || drawn.height < 4) return;
5021
+ const overlay = event.currentTarget.getBoundingClientRect();
5022
+ const range = rangeInBox(article.current, new DOMRect(overlay.left + drawn.left, overlay.top + drawn.top, drawn.width, drawn.height));
5023
+ if (range === void 0) {
5024
+ setNotice("Drag over some text to mark it.");
5025
+ return;
5026
+ }
5027
+ markRendered(range, "region");
5028
+ };
5029
+ const overlay = annotating ? /* @__PURE__ */ jsx("div", {
5030
+ "data-testid": "author-text-overlay",
5031
+ className: `absolute inset-0 z-10 touch-none ${activeTool === "mark" ? "cursor-crosshair" : "cursor-pointer"}`,
5032
+ onPointerDown: (event) => {
5033
+ if (event.button !== 0) return;
5034
+ event.preventDefault();
5035
+ const bounds = event.currentTarget.getBoundingClientRect();
5036
+ const x = event.clientX - bounds.left;
5037
+ const y = event.clientY - bounds.top;
5038
+ setBox({
5039
+ pointerId: event.pointerId,
5040
+ x,
5041
+ y,
5042
+ left: x,
5043
+ top: y,
5044
+ width: 0,
5045
+ height: 0
5046
+ });
5047
+ event.currentTarget.setPointerCapture(event.pointerId);
5048
+ },
5049
+ onPointerMove: (event) => {
5050
+ if (activeTool === "mark" && box?.pointerId === event.pointerId) setBox(drawBox(event, box));
5051
+ },
5052
+ onPointerUp: finishAnnotation,
5053
+ onPointerCancel: () => setBox(void 0),
5054
+ onWheel: (event) => preview ? void 0 : host.current?.querySelector(".cm-scroller")?.scrollBy({
5055
+ left: event.deltaX,
5056
+ top: event.deltaY
5057
+ }),
5058
+ children: box === void 0 || activeTool !== "mark" ? null : /* @__PURE__ */ jsx("div", {
5059
+ "data-testid": "author-text-selection",
5060
+ className: "pointer-events-none absolute border-2 border-dashed border-po-magenta bg-po-magenta/10",
5061
+ style: {
5062
+ left: box.left,
5063
+ top: box.top,
5064
+ width: box.width,
5065
+ height: box.height
5066
+ }
5067
+ })
5068
+ }) : null;
4631
5069
  return /* @__PURE__ */ jsxs("div", {
4632
5070
  ref: host,
4633
5071
  className: "flex min-h-0 flex-1 flex-col",
4634
- children: [preview ? /* @__PURE__ */ jsx("div", {
4635
- "data-testid": "author-markdown",
4636
- className: "min-h-0 flex-1 overflow-auto bg-po-deep/50 p-6",
4637
- children: /* @__PURE__ */ jsx("article", {
4638
- className: "mx-auto min-h-full max-w-3xl rounded-md bg-po-panel px-6 py-8 sm:px-10 text-lg leading-relaxed text-po-text shadow-sm ring-1 ring-po-border-soft",
4639
- children: /* @__PURE__ */ jsx(Markdown, {
4640
- text: isMermaidPath(document.path) ? `\`\`\`\`mermaid\n${document.content ?? ""}\n\`\`\`\`` : document.content ?? "",
4641
- loadImage,
4642
- documentPath: document.path,
4643
- renderMediaReview
5072
+ children: [
5073
+ formatting ? /* @__PURE__ */ jsxs("div", {
5074
+ role: "toolbar",
5075
+ "aria-label": "Format Markdown",
5076
+ "data-testid": "author-format-toolbar",
5077
+ className: "flex shrink-0 items-center gap-1 border-b border-po-border bg-po-bg px-3 py-1.5",
5078
+ children: [FORMATS.map(({ format: style, label, glyph }) => /* @__PURE__ */ jsxs(Button, {
5079
+ size: "sm",
5080
+ variant: "ghost",
5081
+ "aria-label": label,
5082
+ title: label,
5083
+ className: "min-h-11 gap-1.5 [@media(pointer:fine)]:min-h-8",
5084
+ onMouseDown: (event) => event.preventDefault(),
5085
+ onClick: () => format(style),
5086
+ children: [/* @__PURE__ */ jsx("span", {
5087
+ "aria-hidden": "true",
5088
+ className: "font-semibold",
5089
+ children: glyph
5090
+ }), /* @__PURE__ */ jsx("span", {
5091
+ className: "text-sm",
5092
+ children: label
5093
+ })]
5094
+ }, style)), /* @__PURE__ */ jsx("span", {
5095
+ className: "ml-auto truncate text-sm text-po-dim",
5096
+ children: "Select text, then format it."
5097
+ })]
5098
+ }) : null,
5099
+ preview ? /* @__PURE__ */ jsx("div", {
5100
+ "data-testid": "author-markdown",
5101
+ className: "min-h-0 flex-1 overflow-auto bg-po-deep/50 p-6",
5102
+ children: /* @__PURE__ */ jsxs("div", {
5103
+ className: "relative mx-auto min-h-full max-w-3xl",
5104
+ children: [
5105
+ /* @__PURE__ */ jsx("article", {
5106
+ ref: article,
5107
+ onPointerUp: markSelection,
5108
+ onKeyUp: markSelection,
5109
+ className: "min-h-full rounded-md bg-po-panel px-6 py-8 sm:px-10 text-lg leading-relaxed text-po-text shadow-sm ring-1 ring-po-border-soft [&::highlight(po-author-note)]:bg-po-tint-magenta [&_*::highlight(po-author-note)]:bg-po-tint-magenta",
5110
+ children: /* @__PURE__ */ jsx(Markdown, {
5111
+ text: isMermaidPath(document.path) ? `\`\`\`\`mermaid\n${document.content ?? ""}\n\`\`\`\`` : document.content ?? "",
5112
+ loadImage,
5113
+ documentPath: document.path,
5114
+ renderMediaReview
5115
+ })
5116
+ }),
5117
+ badges.map((badge, index) => /* @__PURE__ */ jsx("span", {
5118
+ "aria-hidden": "true",
5119
+ "data-author-region": badge.label,
5120
+ className: `pointer-events-none absolute z-10 flex h-6 min-w-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 px-1 text-sm font-semibold ${badge.label === CANDIDATE_LABEL ? "border-po-magenta bg-po-panel text-po-magenta" : "border-po-panel bg-po-magenta text-po-rail"}`,
5121
+ style: {
5122
+ left: badge.left,
5123
+ top: badge.top
5124
+ },
5125
+ children: badge.label
5126
+ }, `${badge.label}-${String(index)}`)),
5127
+ overlay
5128
+ ]
4644
5129
  })
5130
+ }) : /* @__PURE__ */ jsxs("div", {
5131
+ className: "relative flex min-h-0 flex-1 flex-col",
5132
+ children: [/* @__PURE__ */ jsx(CodeEditor, {
5133
+ value: document.content ?? "",
5134
+ path: document.path,
5135
+ "data-testid": "author-editor",
5136
+ controllerRef: editor,
5137
+ className: "min-h-0 flex-1",
5138
+ onChange: (content) => reviseAuthorDocument(sessionId, document.path, content),
5139
+ onSelect: (range) => {
5140
+ selected.current = {
5141
+ from: range.from,
5142
+ to: range.to
5143
+ };
5144
+ const current = authorDocument(sessionId, document.path);
5145
+ const bounds = host.current?.getBoundingClientRect();
5146
+ if (!current || !bounds || range.from === range.to) return;
5147
+ setAuthorRegionCandidate(sessionId, document.path, {
5148
+ documentPath: current.path,
5149
+ revision: current.version,
5150
+ sourceSha256: current.sourceSha256,
5151
+ quote: range.text,
5152
+ anchor: {
5153
+ kind: "text-range",
5154
+ startOffset: range.from,
5155
+ endOffset: range.to,
5156
+ startLine: range.startLine,
5157
+ endLine: range.endLine
5158
+ },
5159
+ viewport: {
5160
+ width: bounds.width,
5161
+ height: bounds.height
5162
+ },
5163
+ createdAt: Date.now()
5164
+ });
5165
+ }
5166
+ }), overlay]
5167
+ }),
5168
+ notice === void 0 ? null : /* @__PURE__ */ jsx("output", {
5169
+ className: "shrink-0 border-t border-po-border bg-po-bg px-4 py-1.5 text-sm text-po-dim",
5170
+ children: notice
5171
+ }),
5172
+ /* @__PURE__ */ jsxs("footer", {
5173
+ className: "flex shrink-0 items-center gap-2 border-t border-po-border bg-po-bg px-4 py-1.5 text-sm text-po-dim",
5174
+ children: [
5175
+ /* @__PURE__ */ jsx("span", {
5176
+ className: "first-letter:uppercase",
5177
+ children: document.kind
5178
+ }),
5179
+ /* @__PURE__ */ jsx("span", {
5180
+ "aria-hidden": "true",
5181
+ children: "·"
5182
+ }),
5183
+ /* @__PURE__ */ jsxs("span", { children: [
5184
+ lines,
5185
+ " line",
5186
+ lines === 1 ? "" : "s"
5187
+ ] })
5188
+ ]
4645
5189
  })
4646
- }) : /* @__PURE__ */ jsx(CodeEditor, {
4647
- value: document.content ?? "",
4648
- path: document.path,
4649
- "data-testid": "author-editor",
4650
- controllerRef: editor,
4651
- className: "min-h-0 flex-1",
4652
- onChange: (content) => reviseAuthorDocument(sessionId, document.path, content),
4653
- onSelect: (range) => {
4654
- const current = authorDocument(sessionId, document.path);
4655
- const bounds = host.current?.getBoundingClientRect();
4656
- if (!current || !bounds || range.from === range.to) return;
4657
- setAuthorRegionCandidate(sessionId, document.path, {
4658
- documentPath: current.path,
4659
- revision: current.version,
4660
- sourceSha256: current.sourceSha256,
4661
- quote: range.text,
4662
- anchor: {
4663
- kind: "text-range",
4664
- startOffset: range.from,
4665
- endOffset: range.to,
4666
- startLine: range.startLine,
4667
- endLine: range.endLine
4668
- },
4669
- viewport: {
4670
- width: bounds.width,
4671
- height: bounds.height
4672
- },
4673
- createdAt: Date.now()
4674
- });
4675
- }
4676
- }), /* @__PURE__ */ jsxs("footer", {
4677
- className: "flex shrink-0 items-center gap-2 border-t border-po-border bg-po-bg px-4 py-1.5 text-sm text-po-dim",
4678
- children: [
4679
- /* @__PURE__ */ jsx("span", {
4680
- className: "first-letter:uppercase",
4681
- children: document.kind
4682
- }),
4683
- /* @__PURE__ */ jsx("span", {
4684
- "aria-hidden": "true",
4685
- children: "·"
4686
- }),
4687
- /* @__PURE__ */ jsxs("span", { children: [
4688
- lines,
4689
- " line",
4690
- lines === 1 ? "" : "s"
4691
- ] })
4692
- ]
4693
- })]
5190
+ ]
4694
5191
  });
4695
5192
  }
4696
5193
  //#endregion
@@ -4704,6 +5201,13 @@ const MOBILE_TOOL_GLYPHS = {
4704
5201
  };
4705
5202
  const pendingCanvases = /* @__PURE__ */ new Map();
4706
5203
  const canvasOwners = /* @__PURE__ */ new Map();
5204
+ const reason = (error) => error instanceof Error ? error.message : String(error);
5205
+ /** Closing a canvas nobody shows is best effort, but a failure is reported, never lost. */
5206
+ function closeCanvas(sessionId, alias) {
5207
+ api.session(sessionId).bridgeClose({ body: { alias } }).then((result) => {
5208
+ if (!result.ok) console.warn(`Author canvas '${alias}' did not close: ${result.error}`);
5209
+ }).catch((error) => console.warn(`Author canvas '${alias}' did not close: ${reason(error)}`));
5210
+ }
4707
5211
  function tabId(path) {
4708
5212
  let hash = 2166136261;
4709
5213
  for (const char of normalizeAuthorPath(path)) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
@@ -4810,8 +5314,10 @@ function AuthorDocumentPanel(props) {
4810
5314
  const textPanel = /* @__PURE__ */ jsx(AuthorTextView, {
4811
5315
  sessionId,
4812
5316
  document,
4813
- preview: document.kind === "markdown" && markdownPreview && activeTool === "select" && !gridVisible,
5317
+ preview: document.kind === "markdown" && markdownPreview && !gridVisible,
5318
+ activeTool,
4814
5319
  displayedRegions,
5320
+ candidate: drafts.candidate,
4815
5321
  renderMediaReview: markdownMediaReview(props)
4816
5322
  });
4817
5323
  const sourcePanel = document.kind === "html" && !htmlSource ? /* @__PURE__ */ jsx(AuthorHtmlView, {
@@ -5049,7 +5555,7 @@ function authorFileTab(path, preferredAlias) {
5049
5555
  const opened = result.data;
5050
5556
  const canonicalAlias = opened.alias;
5051
5557
  if (controller.signal.aborted || pendingCanvases.get(key) !== controller) {
5052
- if ((canvasOwners.get(`${sessionId}\n${canonicalAlias}`)?.size ?? 0) === 0) api.session(sessionId).bridgeClose({ body: { alias: canonicalAlias } }).catch(() => void 0);
5558
+ if ((canvasOwners.get(`${sessionId}\n${canonicalAlias}`)?.size ?? 0) === 0) closeCanvas(sessionId, canonicalAlias);
5053
5559
  return;
5054
5560
  }
5055
5561
  canvasAliases.setState((state) => ({
@@ -5096,10 +5602,11 @@ function authorFileTab(path, preferredAlias) {
5096
5602
  body: { path: normalized },
5097
5603
  query: { session: sessionId }
5098
5604
  }).then((result) => {
5099
- if (!result.ok || !("alias" in result.data) || typeof result.data.alias !== "string") return;
5605
+ if (!result.ok) throw new Error(result.error);
5606
+ if (!("alias" in result.data) || typeof result.data.alias !== "string") return;
5100
5607
  const recoveredAlias = result.data.alias;
5101
- if ((canvasOwners.get(`${sessionId}\n${recoveredAlias}`)?.size ?? 0) === 0) api.session(sessionId).bridgeClose({ body: { alias: recoveredAlias } }).catch(() => void 0);
5102
- }).catch(() => void 0);
5608
+ if ((canvasOwners.get(`${sessionId}\n${recoveredAlias}`)?.size ?? 0) === 0) closeCanvas(sessionId, recoveredAlias);
5609
+ }).catch((error) => console.warn(`Author could not check for a canvas left open for '${normalized}': ${reason(error)}`));
5103
5610
  return;
5104
5611
  }
5105
5612
  const owners = canvasOwners.get(`${sessionId}\n${alias}`);
@@ -5122,7 +5629,7 @@ function authorFileTab(path, preferredAlias) {
5122
5629
  delete next[key];
5123
5630
  return next;
5124
5631
  });
5125
- if (lastOwner) api.session(sessionId).bridgeClose({ body: { alias } }).catch(() => void 0);
5632
+ if (lastOwner) closeCanvas(sessionId, alias);
5126
5633
  },
5127
5634
  panel: (props) => /* @__PURE__ */ jsx(AuthorDocumentPanel, {
5128
5635
  ...props,