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.
- package/mcp/dist/src/tools/capabilities.d.ts +3 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +4 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +46 -6
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +2 -2
- package/tui/src/app.js +10 -0
- package/tui/src/node-sdk-transport.js +103 -0
- package/ui/core/src/controller.js +205 -4
- package/ui/core/src/history.js +6 -0
- package/ui/core/src/reducer.js +28 -7
- package/ui/core/src/selectors.js +31 -2
- package/ui/react/src/web-app.js +278 -12
- package/web/api/router.js +7 -0
- package/web/dist/assets/{index-CFbG5y06.css → index-CxkLM8_a.css} +1 -1
- package/web/dist/assets/{index-Cae-9EH8.js → index-DdPfXk16.js} +2 -2
- package/web/dist/assets/pilotswarm-DgoJR1qM.js +90 -0
- package/web/dist/index.html +3 -3
- package/web/server.js +5 -0
- package/web/dist/assets/pilotswarm-Drj2bs6t.js +0 -90
package/ui/react/src/web-app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
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,
|