pilotswarm 0.5.17 → 0.5.18

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.
@@ -423,8 +423,9 @@ function normalizeLines(lines) {
423
423
  }
424
424
  // Sentinel kinds preserved as-is so parseStructuredChatBlocks can
425
425
  // recognize and render them (e.g. markdownTable → HTML <table>,
426
- // cardStart/cardEnd → styled card with structured body).
427
- if (line?.kind === "markdownTable" || line?.kind === "cardStart" || line?.kind === "cardEnd") {
426
+ // cardStart/cardEnd → styled card with structured body,
427
+ // imageAttachments → authenticated thumbnail strip).
428
+ if (line?.kind === "markdownTable" || line?.kind === "cardStart" || line?.kind === "cardEnd" || line?.kind === "imageAttachments") {
428
429
  normalized.push(line);
429
430
  continue;
430
431
  }
@@ -1291,7 +1292,7 @@ function formatArtifactPreviewBytes(value) {
1291
1292
  return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;
1292
1293
  }
1293
1294
 
1294
- function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, contentType, sizeBytes, source, uploadedAt }) {
1295
+ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, contentType, sizeBytes, source, uploadedAt, controller = null, sessionId = null }) {
1295
1296
  const meta = [];
1296
1297
  if (contentType) meta.push(contentType);
1297
1298
  if (sizeBytes != null) meta.push(formatArtifactPreviewBytes(sizeBytes));
@@ -1306,9 +1307,23 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
1306
1307
  })
1307
1308
  : "";
1308
1309
 
1310
+ // Raster images render inline (authenticated fetch → object URL). Other
1311
+ // binary types keep the download-only card.
1312
+ const isRasterImage = /^image\/(png|jpeg|gif|webp)$/i.test(String(contentType || ""));
1313
+ const [imageUrl, setImageUrl] = React.useState(null);
1314
+ React.useEffect(() => {
1315
+ setImageUrl(null);
1316
+ if (!isRasterImage || !controller || !sessionId || !filename) return undefined;
1317
+ let cancelled = false;
1318
+ fetchArtifactObjectUrl(controller, sessionId, filename)
1319
+ .then((url) => { if (!cancelled) setImageUrl(url); })
1320
+ .catch(() => { if (!cancelled) setImageUrl("error"); });
1321
+ return () => { cancelled = true; };
1322
+ }, [isRasterImage, controller, sessionId, filename]);
1323
+
1309
1324
  return React.createElement(Panel, { title, color, focused, theme },
1310
1325
  React.createElement("div", { className: "ps-binary-preview-card" },
1311
- React.createElement("div", { className: "ps-binary-preview-kicker" }, "Binary artifact"),
1326
+ React.createElement("div", { className: "ps-binary-preview-kicker" }, isRasterImage ? "Image artifact" : "Binary artifact"),
1312
1327
  React.createElement("h3", { className: "ps-binary-preview-title" }, filename || "Artifact"),
1313
1328
  meta.length > 0
1314
1329
  ? React.createElement("div", { className: "ps-binary-preview-meta" }, meta.join(" • "))
@@ -1316,8 +1331,17 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
1316
1331
  uploadedLabel
1317
1332
  ? React.createElement("div", { className: "ps-binary-preview-time" }, `Uploaded ${uploadedLabel}`)
1318
1333
  : null,
1319
- React.createElement("p", { className: "ps-binary-preview-copy" },
1320
- "Preview is intentionally disabled for non-text artifacts in the browser workspace. Use Download to save the file and open it in the default app.")));
1334
+ isRasterImage && imageUrl && imageUrl !== "error"
1335
+ ? React.createElement("img", {
1336
+ className: "ps-binary-preview-image",
1337
+ src: imageUrl,
1338
+ alt: filename || "artifact image",
1339
+ onClick: () => { try { window.open(imageUrl, "_blank", "noopener"); } catch { /* popup blocked */ } },
1340
+ })
1341
+ : React.createElement("p", { className: "ps-binary-preview-copy" },
1342
+ isRasterImage
1343
+ ? (imageUrl === "error" ? "Could not load the image preview. Use Download to save the file." : "Loading image preview…")
1344
+ : "Preview is intentionally disabled for non-text artifacts in the browser workspace. Use Download to save the file and open it in the default app.")));
1321
1345
  }
1322
1346
 
1323
1347
  function isBoxTopLine(text) {
@@ -1473,6 +1497,19 @@ function parseStructuredChatBlocks(lines = []) {
1473
1497
  continue;
1474
1498
  }
1475
1499
 
1500
+ // Sentinel image-attachment line — a user message's image refs. The
1501
+ // block component fetches bytes through the authenticated transport
1502
+ // (a bare <img src=download-url> cannot carry the Bearer token).
1503
+ if (currentLine?.kind === "imageAttachments") {
1504
+ blocks.push({
1505
+ type: "imageAttachments",
1506
+ sessionId: currentLine.sessionId || null,
1507
+ attachments: Array.isArray(currentLine.attachments) ? currentLine.attachments : [],
1508
+ });
1509
+ index += 1;
1510
+ continue;
1511
+ }
1512
+
1476
1513
  // Sentinel markdown-table line emitted by parseMarkdownLines when
1477
1514
  // tableMode === "sentinel". Carries the raw header + rows so the
1478
1515
  // portal renders a real HTML table with markdown cell content (so
@@ -1619,17 +1656,105 @@ function parseStructuredChatBlocks(lines = []) {
1619
1656
  return blocks;
1620
1657
  }
1621
1658
 
1622
- function StructuredChatBlocks({ lines, theme }) {
1659
+ function StructuredChatBlocks({ lines, theme, controller = null }) {
1623
1660
  const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
1624
- return React.createElement(StructuredBlockList, { blocks, theme });
1661
+ return React.createElement(StructuredBlockList, { blocks, theme, controller });
1662
+ }
1663
+
1664
+ // Authenticated artifact thumbnails: bytes are fetched through the transport
1665
+ // (Bearer token) and served to <img> as object URLs. Module-level cache so
1666
+ // scrolling the transcript doesn't refetch; oldest entries are revoked at cap.
1667
+ const ARTIFACT_THUMB_CACHE = new Map();
1668
+ const ARTIFACT_THUMB_CACHE_MAX = 60;
1669
+
1670
+ function fetchArtifactObjectUrl(controller, sessionId, filename) {
1671
+ const key = `${sessionId}/${filename}`;
1672
+ const cached = ARTIFACT_THUMB_CACHE.get(key);
1673
+ if (cached) {
1674
+ // Refresh LRU position.
1675
+ ARTIFACT_THUMB_CACHE.delete(key);
1676
+ ARTIFACT_THUMB_CACHE.set(key, cached);
1677
+ return cached;
1678
+ }
1679
+ const downloadResponse = controller?.transport?.api?.downloadArtifactResponse;
1680
+ if (typeof downloadResponse !== "function") {
1681
+ return Promise.reject(new Error("artifact download unavailable"));
1682
+ }
1683
+ const promise = downloadResponse.call(controller.transport.api, sessionId, filename)
1684
+ .then(async (response) => {
1685
+ if (!response?.ok) throw new Error(`download failed (${response?.status})`);
1686
+ const blob = await response.blob();
1687
+ return URL.createObjectURL(blob);
1688
+ })
1689
+ .catch((error) => {
1690
+ // Failed fetches must not be cached — a retry on next render is fine.
1691
+ if (ARTIFACT_THUMB_CACHE.get(key) === promise) ARTIFACT_THUMB_CACHE.delete(key);
1692
+ throw error;
1693
+ });
1694
+ ARTIFACT_THUMB_CACHE.set(key, promise);
1695
+ while (ARTIFACT_THUMB_CACHE.size > ARTIFACT_THUMB_CACHE_MAX) {
1696
+ const [oldestKey, oldest] = ARTIFACT_THUMB_CACHE.entries().next().value;
1697
+ ARTIFACT_THUMB_CACHE.delete(oldestKey);
1698
+ Promise.resolve(oldest).then((url) => URL.revokeObjectURL(url)).catch(() => {});
1699
+ }
1700
+ return promise;
1701
+ }
1702
+
1703
+ function ArtifactImageStrip({ controller, sessionId, attachments }) {
1704
+ const [urls, setUrls] = React.useState({});
1705
+ React.useEffect(() => {
1706
+ if (!controller || !sessionId) return undefined;
1707
+ let cancelled = false;
1708
+ for (const attachment of attachments) {
1709
+ const filename = attachment?.filename;
1710
+ if (!filename) continue;
1711
+ fetchArtifactObjectUrl(controller, sessionId, filename)
1712
+ .then((url) => {
1713
+ if (!cancelled) setUrls((prev) => (prev[filename] === url ? prev : { ...prev, [filename]: url }));
1714
+ })
1715
+ .catch(() => {
1716
+ if (!cancelled) setUrls((prev) => (prev[filename] === "error" ? prev : { ...prev, [filename]: "error" }));
1717
+ });
1718
+ }
1719
+ return () => { cancelled = true; };
1720
+ }, [controller, sessionId, attachments]);
1721
+
1722
+ return React.createElement("div", { className: "ps-chat-image-strip" },
1723
+ attachments.map((attachment, index) => {
1724
+ const filename = attachment?.filename || `image-${index}`;
1725
+ const url = urls[filename];
1726
+ if (url && url !== "error") {
1727
+ return React.createElement("img", {
1728
+ key: `thumb:${index}:${filename}`,
1729
+ className: "ps-chat-image-thumb",
1730
+ src: url,
1731
+ alt: filename,
1732
+ title: `${filename} — click to view full size`,
1733
+ onClick: () => { try { window.open(url, "_blank", "noopener"); } catch { /* popup blocked */ } },
1734
+ });
1735
+ }
1736
+ return React.createElement("div", {
1737
+ key: `thumb:${index}:${filename}`,
1738
+ className: `ps-chat-image-thumb is-pending${url === "error" ? " is-error" : ""}`,
1739
+ title: filename,
1740
+ }, url === "error" ? "⚠" : "🖼");
1741
+ }));
1625
1742
  }
1626
1743
 
1627
1744
  // Renders parsed chat blocks; sentinel card blocks recurse through this list
1628
1745
  // so structured content (box/markdown tables, code fences) inside a card
1629
1746
  // renders exactly the same as it does at top level.
1630
- function StructuredBlockList({ blocks, theme }) {
1747
+ function StructuredBlockList({ blocks, theme, controller = null }) {
1631
1748
  return React.createElement(React.Fragment, null,
1632
1749
  (blocks || []).map((block, index) => {
1750
+ if (block.type === "imageAttachments") {
1751
+ return React.createElement(ArtifactImageStrip, {
1752
+ key: `imageAttachments:${index}`,
1753
+ controller,
1754
+ sessionId: block.sessionId,
1755
+ attachments: block.attachments,
1756
+ });
1757
+ }
1633
1758
  if (block.type === "preserve") {
1634
1759
  const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
1635
1760
  return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
@@ -1658,7 +1783,7 @@ function StructuredBlockList({ blocks, theme }) {
1658
1783
  React.createElement(Runs, { runs: block.headerRuns, theme })),
1659
1784
  React.createElement("div", { className: "ps-chat-card-body" },
1660
1785
  Array.isArray(block.blocks)
1661
- ? React.createElement(StructuredBlockList, { blocks: block.blocks, theme })
1786
+ ? React.createElement(StructuredBlockList, { blocks: block.blocks, theme, controller })
1662
1787
  : (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
1663
1788
  key: `card:${index}:line:${bodyIndex}`,
1664
1789
  className: "ps-chat-card-line",
@@ -1918,7 +2043,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1918
2043
  typeof renderBody === "function"
1919
2044
  ? renderBody(normalizedLines, theme)
1920
2045
  : structuredBlocks
1921
- ? React.createElement(StructuredChatBlocks, { lines: normalizedLines, theme })
2046
+ ? React.createElement(StructuredChatBlocks, { lines: normalizedLines, theme, controller })
1922
2047
  : normalizedLines.map((line, index) => React.createElement(Line, { key: `line:${index}`, line, theme })),
1923
2048
  ),
1924
2049
  normalizedBottomSticky.length > 0
@@ -2986,6 +3111,8 @@ function FilesPane({ controller, focused, mobile = false }) {
2986
3111
  sizeBytes: filesView.previewSizeBytes,
2987
3112
  source: filesView.previewSource,
2988
3113
  uploadedAt: filesView.previewUploadedAt,
3114
+ controller,
3115
+ sessionId: filesView.selectedSessionId,
2989
3116
  })
2990
3117
  : React.createElement(ScrollLinesPanel, {
2991
3118
  controller,
@@ -3328,6 +3455,15 @@ function ChatFocusWorkspace({ controller, openPane, onTogglePane, onExitFocus, m
3328
3455
  })));
3329
3456
  }
3330
3457
 
3458
+ const EMPTY_ARRAY = Object.freeze([]);
3459
+
3460
+ function formatAttachmentSize(sizeBytes) {
3461
+ const bytes = Number(sizeBytes) || 0;
3462
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3463
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
3464
+ return `${bytes} B`;
3465
+ }
3466
+
3331
3467
  function PromptComposer({ controller, mobile, active = true, onAfterSend = null }) {
3332
3468
  const promptState = useControllerSelector(controller, (state) => {
3333
3469
  const activeSessionId = state.sessions.activeSessionId;
@@ -3349,9 +3485,54 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3349
3485
  selectedOutboxPhase: state.ui.promptEdit?.sessionId === activeSessionId
3350
3486
  ? state.ui.promptEdit?.phase || null
3351
3487
  : null,
3488
+ // Raw reference (stable across renders) — image entries are
3489
+ // filtered at render time so the shallow-equal selector holds.
3490
+ promptAttachments: state.ui.promptAttachments || EMPTY_ARRAY,
3352
3491
  };
3353
3492
  }, shallowEqualObject);
3354
3493
  const inputRef = React.useRef(null);
3494
+ const attachInputRef = React.useRef(null);
3495
+ const [dragOver, setDragOver] = React.useState(false);
3496
+
3497
+ const canAttachImages = typeof controller.transport?.supportsPromptImageAttachments === "function"
3498
+ && controller.transport.supportsPromptImageAttachments()
3499
+ && typeof controller.transport?.uploadArtifactFromFile === "function";
3500
+ const imageAttachments = canAttachImages
3501
+ ? promptState.promptAttachments.filter((a) => a?.kind === "image")
3502
+ : EMPTY_ARRAY;
3503
+
3504
+ // Object URLs for chip thumbnails — created per staged File, revoked when
3505
+ // the chip leaves (send/remove) or the composer unmounts.
3506
+ const thumbUrlsRef = React.useRef(new Map());
3507
+ React.useEffect(() => {
3508
+ const urls = thumbUrlsRef.current;
3509
+ const liveFiles = new Set(imageAttachments.map((a) => a.file));
3510
+ for (const [file, url] of urls) {
3511
+ if (!liveFiles.has(file)) {
3512
+ URL.revokeObjectURL(url);
3513
+ urls.delete(file);
3514
+ }
3515
+ }
3516
+ for (const attachment of imageAttachments) {
3517
+ if (attachment.file && !urls.has(attachment.file)) {
3518
+ try {
3519
+ urls.set(attachment.file, URL.createObjectURL(attachment.file));
3520
+ } catch { /* thumbnail is a nicety — the chip still renders */ }
3521
+ }
3522
+ }
3523
+ }, [imageAttachments]);
3524
+ React.useEffect(() => () => {
3525
+ for (const url of thumbUrlsRef.current.values()) URL.revokeObjectURL(url);
3526
+ thumbUrlsRef.current.clear();
3527
+ }, []);
3528
+
3529
+ const stageImageFiles = React.useCallback((files) => {
3530
+ if (!canAttachImages) return 0;
3531
+ const list = Array.from(files || []).filter((f) => f && typeof f.type === "string" && f.type.startsWith("image/"));
3532
+ if (list.length === 0) return 0;
3533
+ const result = controller.addPendingImageFiles(list);
3534
+ return result?.accepted || 0;
3535
+ }, [controller, canAttachImages]);
3355
3536
 
3356
3537
  React.useEffect(() => {
3357
3538
  const inputNode = inputRef.current;
@@ -3406,8 +3587,52 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3406
3587
  const selectedReadOnly = selectedQueued || selectedCancelling;
3407
3588
 
3408
3589
  return React.createElement("div", {
3409
- className: `ps-prompt-shell${mobile ? " is-mobile" : ""}`,
3590
+ className: `ps-prompt-shell${mobile ? " is-mobile" : ""}${dragOver ? " is-drag-over" : ""}`,
3591
+ ...(canAttachImages ? {
3592
+ onDragOver: (event) => {
3593
+ if (event.dataTransfer?.types?.includes?.("Files")) {
3594
+ event.preventDefault();
3595
+ setDragOver(true);
3596
+ }
3597
+ },
3598
+ onDragLeave: (event) => {
3599
+ if (!event.currentTarget.contains(event.relatedTarget)) setDragOver(false);
3600
+ },
3601
+ onDrop: (event) => {
3602
+ setDragOver(false);
3603
+ if (event.dataTransfer?.files?.length) {
3604
+ event.preventDefault();
3605
+ stageImageFiles(event.dataTransfer.files);
3606
+ }
3607
+ },
3608
+ } : {}),
3410
3609
  },
3610
+ imageAttachments.length > 0
3611
+ ? React.createElement("div", { className: "ps-prompt-attachments" },
3612
+ imageAttachments.map((attachment, index) => {
3613
+ const thumbUrl = attachment.file ? thumbUrlsRef.current.get(attachment.file) : null;
3614
+ // No filename in the chip: pasted images all arrive as the
3615
+ // browser's generic "image.png", and the name is replaced
3616
+ // by a deterministic artifact name at send anyway. The
3617
+ // thumbnail IS the identity; the name survives as a tooltip.
3618
+ return React.createElement("div", {
3619
+ key: `attach:${index}:${attachment.filename}`,
3620
+ className: "ps-attach-chip",
3621
+ title: `${attachment.filename} · ${formatAttachmentSize(attachment.sizeBytes)}`,
3622
+ },
3623
+ thumbUrl
3624
+ ? React.createElement("img", { className: "ps-attach-thumb", src: thumbUrl, alt: attachment.filename })
3625
+ : React.createElement("div", { className: "ps-attach-thumb is-placeholder" }, "🖼"),
3626
+ React.createElement("div", { className: "ps-attach-size" }, formatAttachmentSize(attachment.sizeBytes)),
3627
+ React.createElement("button", {
3628
+ type: "button",
3629
+ className: "ps-attach-remove",
3630
+ title: `Remove ${attachment.filename}`,
3631
+ "aria-label": `Remove attachment ${attachment.filename}`,
3632
+ onClick: () => controller.removePendingImageAttachment(index),
3633
+ }, "✕"));
3634
+ }))
3635
+ : null,
3411
3636
  React.createElement("label", { className: "ps-prompt-label" }, promptState.answerMode ? "answer" : selectedCancelling ? "cancelling" : selectedQueued ? "queued" : promptState.editingPending ? "pending" : "you"),
3412
3637
  React.createElement("textarea", {
3413
3638
  ref: inputRef,
@@ -3430,6 +3655,21 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3430
3655
  onFocus: () => controller.setFocus("prompt"),
3431
3656
  onSelect: (event) => controller.setPromptCursor(event.currentTarget.selectionStart || 0),
3432
3657
  onChange: (event) => controller.setPrompt(event.currentTarget.value, event.currentTarget.selectionStart || event.currentTarget.value.length),
3658
+ // Ctrl/Cmd+V of a copied image (or iOS/Android long-press → Paste):
3659
+ // clipboardData.items carries the image file(s). preventDefault only
3660
+ // when we actually staged one, so text pastes are untouched.
3661
+ onPaste: canAttachImages
3662
+ ? (event) => {
3663
+ const items = Array.from(event.clipboardData?.items || []);
3664
+ const files = items
3665
+ .filter((item) => item.kind === "file" && item.type.startsWith("image/"))
3666
+ .map((item) => item.getAsFile())
3667
+ .filter(Boolean);
3668
+ if (files.length > 0 && stageImageFiles(files) > 0) {
3669
+ event.preventDefault();
3670
+ }
3671
+ }
3672
+ : undefined,
3433
3673
  onKeyDown: (event) => {
3434
3674
  if (event.key === "Tab" && !event.shiftKey && controller.acceptPromptReferenceAutocomplete()) {
3435
3675
  event.preventDefault();
@@ -3461,6 +3701,32 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3461
3701
  },
3462
3702
  }),
3463
3703
  React.createElement("div", { className: "ps-prompt-actions" },
3704
+ canAttachImages
3705
+ ? React.createElement(React.Fragment, null,
3706
+ React.createElement("input", {
3707
+ ref: attachInputRef,
3708
+ type: "file",
3709
+ // image/* (not the exact MIME list) so phones offer the
3710
+ // photo library and camera; exact types are validated
3711
+ // on staging and again at the API edge.
3712
+ accept: "image/*",
3713
+ multiple: true,
3714
+ className: "ps-hidden-file-input",
3715
+ "aria-hidden": true,
3716
+ tabIndex: -1,
3717
+ onChange: (event) => {
3718
+ stageImageFiles(event.currentTarget.files);
3719
+ event.currentTarget.value = "";
3720
+ },
3721
+ }),
3722
+ React.createElement("button", {
3723
+ type: "button",
3724
+ className: "ps-mini-button ps-attach-button",
3725
+ title: "Attach images (or paste / drop them into the composer)",
3726
+ "aria-label": "Attach images",
3727
+ onClick: () => attachInputRef.current?.click(),
3728
+ }, "📎"))
3729
+ : null,
3464
3730
  promptState.editingPending && !selectedCancelling
3465
3731
  ? React.createElement("button", {
3466
3732
  type: "button",
package/web/api/router.js CHANGED
@@ -18,6 +18,13 @@ import { getPublicAuthContext } from "../auth/authz/engine.js";
18
18
 
19
19
  const ERROR_STATUS_BY_CODE = {
20
20
  INVALID_REQUEST: 400,
21
+ // Artifact upload validation: declared content type contradicts the
22
+ // sniffed bytes, or the body exceeds the artifact size caps.
23
+ ARTIFACT_CONTENT_TYPE_MISMATCH: 400,
24
+ ARTIFACT_TOO_LARGE: 413,
25
+ // Image attachment refs on sendMessage failed validation (missing
26
+ // artifact, non-image type, size/count caps).
27
+ INVALID_ATTACHMENT: 400,
21
28
  // GitHub Copilot model requested without a worker or per-user key.
22
29
  GHCP_KEY_MISSING: 400,
23
30
  PORTAL_AUTH_REQUIRED: 401,