pilotswarm 0.5.16 → 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
  }
@@ -594,6 +595,23 @@ export function isScrollViewportAtBottom(node) {
594
595
  return getScrollDistanceToBottom(node) <= SCROLL_BOTTOM_EPSILON_PX;
595
596
  }
596
597
 
598
+ export function computeAnchoredScrollTop(
599
+ node,
600
+ scrollOffset,
601
+ scrollMode,
602
+ preservePausedStickyScroll = false,
603
+ ) {
604
+ if (!node) return 0;
605
+ const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
606
+ if (preservePausedStickyScroll) {
607
+ return Math.max(0, Math.min(node.scrollTop, maxScroll));
608
+ }
609
+ const offsetPixels = Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT;
610
+ return scrollMode === "bottom"
611
+ ? Math.max(0, maxScroll - offsetPixels)
612
+ : Math.min(maxScroll, offsetPixels);
613
+ }
614
+
597
615
  function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom = false } = {}) {
598
616
  const normalizedLines = React.useMemo(() => normalizeLines(lines), [lines]);
599
617
  // Programmatic scrollTop assignments fire a 'scroll' event that would
@@ -613,6 +631,23 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
613
631
  // or its momentum is in flight, the DOM is the source of truth and state
614
632
  // echoes of our own scroll dispatches must not snap the pane back.
615
633
  const userScrollRef = React.useRef({ touching: false, lastUserScrollAt: 0, lastDispatchedOffset: null });
634
+ const viewportSizeRef = React.useRef({ width: null, height: null });
635
+ const [viewportRevision, setViewportRevision] = React.useState(0);
636
+
637
+ React.useLayoutEffect(() => {
638
+ const node = ref.current;
639
+ if (!node || typeof ResizeObserver === "undefined") return;
640
+ viewportSizeRef.current = { width: node.clientWidth, height: node.clientHeight };
641
+ const observer = new ResizeObserver(() => {
642
+ const next = { width: node.clientWidth, height: node.clientHeight };
643
+ const previous = viewportSizeRef.current;
644
+ if (next.width === previous.width && next.height === previous.height) return;
645
+ viewportSizeRef.current = next;
646
+ setViewportRevision((revision) => revision + 1);
647
+ });
648
+ observer.observe(node);
649
+ return () => observer.disconnect();
650
+ }, [ref]);
616
651
 
617
652
  React.useLayoutEffect(() => {
618
653
  const node = ref.current;
@@ -632,17 +667,16 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
632
667
  previousViewportStateRef.current = { scrollMode, scrollOffset };
633
668
  return;
634
669
  }
635
- const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
636
670
  const preservePausedStickyScroll = stickyBottom
637
671
  && scrollMode === "top"
638
672
  && previousViewportState?.scrollMode === "top"
639
673
  && previousViewportState?.scrollOffset === scrollOffset;
640
- const offsetPixels = Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT;
641
- const nextScrollTop = preservePausedStickyScroll
642
- ? Math.max(0, Math.min(node.scrollTop, maxScroll))
643
- : scrollMode === "bottom"
644
- ? Math.max(0, maxScroll - offsetPixels)
645
- : Math.min(maxScroll, offsetPixels);
674
+ const nextScrollTop = computeAnchoredScrollTop(
675
+ node,
676
+ scrollOffset,
677
+ scrollMode,
678
+ preservePausedStickyScroll,
679
+ );
646
680
  if (Math.abs(node.scrollTop - nextScrollTop) > PROGRAMMATIC_SCROLL_TOLERANCE_PX) {
647
681
  const pendingProgrammaticScroll = { target: nextScrollTop };
648
682
  programmaticScrollRef.current = pendingProgrammaticScroll;
@@ -659,7 +693,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
659
693
  scrollMode,
660
694
  scrollOffset,
661
695
  };
662
- }, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom]);
696
+ }, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom, viewportRevision]);
663
697
 
664
698
  const onScroll = React.useCallback(() => {
665
699
  const node = ref.current;
@@ -1258,7 +1292,7 @@ function formatArtifactPreviewBytes(value) {
1258
1292
  return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;
1259
1293
  }
1260
1294
 
1261
- 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 }) {
1262
1296
  const meta = [];
1263
1297
  if (contentType) meta.push(contentType);
1264
1298
  if (sizeBytes != null) meta.push(formatArtifactPreviewBytes(sizeBytes));
@@ -1273,9 +1307,23 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
1273
1307
  })
1274
1308
  : "";
1275
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
+
1276
1324
  return React.createElement(Panel, { title, color, focused, theme },
1277
1325
  React.createElement("div", { className: "ps-binary-preview-card" },
1278
- React.createElement("div", { className: "ps-binary-preview-kicker" }, "Binary artifact"),
1326
+ React.createElement("div", { className: "ps-binary-preview-kicker" }, isRasterImage ? "Image artifact" : "Binary artifact"),
1279
1327
  React.createElement("h3", { className: "ps-binary-preview-title" }, filename || "Artifact"),
1280
1328
  meta.length > 0
1281
1329
  ? React.createElement("div", { className: "ps-binary-preview-meta" }, meta.join(" • "))
@@ -1283,8 +1331,17 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
1283
1331
  uploadedLabel
1284
1332
  ? React.createElement("div", { className: "ps-binary-preview-time" }, `Uploaded ${uploadedLabel}`)
1285
1333
  : null,
1286
- React.createElement("p", { className: "ps-binary-preview-copy" },
1287
- "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.")));
1288
1345
  }
1289
1346
 
1290
1347
  function isBoxTopLine(text) {
@@ -1440,6 +1497,19 @@ function parseStructuredChatBlocks(lines = []) {
1440
1497
  continue;
1441
1498
  }
1442
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
+
1443
1513
  // Sentinel markdown-table line emitted by parseMarkdownLines when
1444
1514
  // tableMode === "sentinel". Carries the raw header + rows so the
1445
1515
  // portal renders a real HTML table with markdown cell content (so
@@ -1586,17 +1656,105 @@ function parseStructuredChatBlocks(lines = []) {
1586
1656
  return blocks;
1587
1657
  }
1588
1658
 
1589
- function StructuredChatBlocks({ lines, theme }) {
1659
+ function StructuredChatBlocks({ lines, theme, controller = null }) {
1590
1660
  const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
1591
- 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
+ }));
1592
1742
  }
1593
1743
 
1594
1744
  // Renders parsed chat blocks; sentinel card blocks recurse through this list
1595
1745
  // so structured content (box/markdown tables, code fences) inside a card
1596
1746
  // renders exactly the same as it does at top level.
1597
- function StructuredBlockList({ blocks, theme }) {
1747
+ function StructuredBlockList({ blocks, theme, controller = null }) {
1598
1748
  return React.createElement(React.Fragment, null,
1599
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
+ }
1600
1758
  if (block.type === "preserve") {
1601
1759
  const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
1602
1760
  return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
@@ -1625,7 +1783,7 @@ function StructuredBlockList({ blocks, theme }) {
1625
1783
  React.createElement(Runs, { runs: block.headerRuns, theme })),
1626
1784
  React.createElement("div", { className: "ps-chat-card-body" },
1627
1785
  Array.isArray(block.blocks)
1628
- ? React.createElement(StructuredBlockList, { blocks: block.blocks, theme })
1786
+ ? React.createElement(StructuredBlockList, { blocks: block.blocks, theme, controller })
1629
1787
  : (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
1630
1788
  key: `card:${index}:line:${bodyIndex}`,
1631
1789
  className: "ps-chat-card-line",
@@ -1885,7 +2043,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1885
2043
  typeof renderBody === "function"
1886
2044
  ? renderBody(normalizedLines, theme)
1887
2045
  : structuredBlocks
1888
- ? React.createElement(StructuredChatBlocks, { lines: normalizedLines, theme })
2046
+ ? React.createElement(StructuredChatBlocks, { lines: normalizedLines, theme, controller })
1889
2047
  : normalizedLines.map((line, index) => React.createElement(Line, { key: `line:${index}`, line, theme })),
1890
2048
  ),
1891
2049
  normalizedBottomSticky.length > 0
@@ -2953,6 +3111,8 @@ function FilesPane({ controller, focused, mobile = false }) {
2953
3111
  sizeBytes: filesView.previewSizeBytes,
2954
3112
  source: filesView.previewSource,
2955
3113
  uploadedAt: filesView.previewUploadedAt,
3114
+ controller,
3115
+ sessionId: filesView.selectedSessionId,
2956
3116
  })
2957
3117
  : React.createElement(ScrollLinesPanel, {
2958
3118
  controller,
@@ -3295,6 +3455,15 @@ function ChatFocusWorkspace({ controller, openPane, onTogglePane, onExitFocus, m
3295
3455
  })));
3296
3456
  }
3297
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
+
3298
3467
  function PromptComposer({ controller, mobile, active = true, onAfterSend = null }) {
3299
3468
  const promptState = useControllerSelector(controller, (state) => {
3300
3469
  const activeSessionId = state.sessions.activeSessionId;
@@ -3316,9 +3485,54 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3316
3485
  selectedOutboxPhase: state.ui.promptEdit?.sessionId === activeSessionId
3317
3486
  ? state.ui.promptEdit?.phase || null
3318
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,
3319
3491
  };
3320
3492
  }, shallowEqualObject);
3321
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]);
3322
3536
 
3323
3537
  React.useEffect(() => {
3324
3538
  const inputNode = inputRef.current;
@@ -3373,8 +3587,52 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3373
3587
  const selectedReadOnly = selectedQueued || selectedCancelling;
3374
3588
 
3375
3589
  return React.createElement("div", {
3376
- 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
+ } : {}),
3377
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,
3378
3636
  React.createElement("label", { className: "ps-prompt-label" }, promptState.answerMode ? "answer" : selectedCancelling ? "cancelling" : selectedQueued ? "queued" : promptState.editingPending ? "pending" : "you"),
3379
3637
  React.createElement("textarea", {
3380
3638
  ref: inputRef,
@@ -3397,6 +3655,21 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3397
3655
  onFocus: () => controller.setFocus("prompt"),
3398
3656
  onSelect: (event) => controller.setPromptCursor(event.currentTarget.selectionStart || 0),
3399
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,
3400
3673
  onKeyDown: (event) => {
3401
3674
  if (event.key === "Tab" && !event.shiftKey && controller.acceptPromptReferenceAutocomplete()) {
3402
3675
  event.preventDefault();
@@ -3428,6 +3701,32 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
3428
3701
  },
3429
3702
  }),
3430
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,
3431
3730
  promptState.editingPending && !selectedCancelling
3432
3731
  ? React.createElement("button", {
3433
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,
@@ -0,0 +1 @@
1
+ :root{--ps-page-background: #0d1117;--ps-page-foreground: #f0f6fc;--ps-surface: #0d1117;--ps-background: #0d1117;--ps-foreground: #f0f6fc;--ps-muted: #8b949e;--ps-border: #6e7681;--ps-selection-background: #58a6ff;--ps-selection-foreground: #0d1117;--ps-highlight-background: #1f6feb;--ps-highlight-foreground: #ffffff;--ps-modal-backdrop: rgba(13, 17, 23, .72);--ps-modal-background: #161b22;--ps-modal-border: #30363d;--ps-modal-foreground: #f0f6fc;--ps-modal-muted: #8b949e;--ps-modal-selected-background: rgba(31, 111, 235, .16);--ps-modal-selected-border: #58a6ff;--ps-modal-selected-foreground: #ffffff;--ps-font-size-base: 13px;--ps-font-size-dense: 12px;--ps-line-height-base: 1.28;--ps-line-height-dense: 1.18;--ps-radius-sm: 6px;--ps-radius-md: 8px;--ps-scrollbar-size: 10px;--ps-scrollbar-track: rgba(110, 118, 129, .14);--ps-scrollbar-thumb: color-mix(in srgb, var(--ps-border) 56%, var(--ps-surface));--ps-scrollbar-thumb-hover: color-mix(in srgb, var(--ps-modal-selected-border) 42%, var(--ps-border))}*{box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--ps-scrollbar-thumb) var(--ps-scrollbar-track)}html,body,#root{margin:0;min-height:100%;height:100%;background:var(--ps-page-background);color:var(--ps-page-foreground);font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;font-size:var(--ps-font-size-base);line-height:var(--ps-line-height-base)}body{overflow:hidden;-webkit-text-size-adjust:100%}button,input,textarea{font:inherit}button{cursor:pointer}.portal-app-shell,.portal-gate{min-height:var(--ps-app-height, 100%);height:var(--ps-app-height, 100%);display:flex;flex-direction:column;background:radial-gradient(circle at top left,color-mix(in srgb,var(--ps-highlight-background) 18%,transparent),transparent 42%),radial-gradient(circle at top right,color-mix(in srgb,var(--ps-modal-selected-border) 16%,transparent),transparent 38%),var(--ps-page-background)}.portal-gate{align-items:center;justify-content:center;padding:24px}.portal-gate-card{width:min(480px,100%);border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-md);background:color-mix(in srgb,var(--ps-modal-background) 92%,transparent);box-shadow:0 28px 80px #00000052;padding:28px;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.portal-gate-brand{display:flex;flex-direction:column;align-items:flex-start;gap:14px;margin-bottom:18px}.portal-gate-kicker,.portal-header-kicker{color:var(--ps-muted);font-size:12px;letter-spacing:.08em;text-transform:uppercase}.portal-gate-title{margin:10px 0 14px;font-size:clamp(28px,5vw,42px);line-height:1.05}.portal-gate-copy{margin:0;color:var(--ps-muted);line-height:1.6}.portal-primary-button,.portal-secondary-button,.ps-toolbar-button,.ps-mini-button,.ps-send-button,.ps-modal-button,.ps-mobile-nav-button,.ps-tab,.ps-filter-option,.ps-list-button,.ps-modal-close{display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-sm);background:color-mix(in srgb,var(--ps-surface) 94%,transparent);color:var(--ps-foreground);transition:background .12s ease,border-color .12s ease,transform .12s ease}.portal-primary-button,.ps-send-button,.ps-modal-button.is-primary{background:var(--ps-highlight-background);border-color:var(--ps-highlight-background);color:var(--ps-highlight-foreground)}.ps-modal-button.is-danger{background:#dc2626;border-color:#dc2626;color:#fff}.ps-modal-button.is-danger:hover{background:#ef4444;border-color:#ef4444}.portal-primary-button,.portal-secondary-button{padding:10px 14px;margin-top:20px}.portal-secondary-button{margin-top:0;flex:0 0 auto}.portal-header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid color-mix(in srgb,var(--ps-border) 50%,transparent);background:color-mix(in srgb,var(--ps-page-background) 86%,transparent);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)}.portal-header-brand{display:flex;align-items:center;gap:12px;min-width:0}.portal-header-brand-copy{display:flex;flex-direction:column;gap:3px;min-width:0}.portal-logo-frame{width:38px;height:38px;border-radius:var(--ps-radius-sm);border:1px solid color-mix(in srgb,var(--ps-modal-selected-border) 52%,var(--ps-modal-border));background:radial-gradient(circle at 28% 24%,color-mix(in srgb,var(--ps-modal-selected-border) 20%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--ps-highlight-background) 20%,var(--ps-surface)),color-mix(in srgb,var(--ps-background) 92%,transparent));box-shadow:inset 0 1px color-mix(in srgb,white 10%,transparent),0 10px 24px #0000002e;display:grid;place-items:center;flex:0 0 auto;overflow:hidden}.portal-logo-frame.is-large{width:76px;height:76px;border-radius:16px}.portal-logo-frame.has-image{background:radial-gradient(circle at 28% 24%,color-mix(in srgb,white 10%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--ps-surface) 94%,white 6%),color-mix(in srgb,var(--ps-background) 90%,transparent))}.portal-logo{width:24px;height:24px}.portal-logo-frame.is-large .portal-logo{width:44px;height:44px}.portal-logo-image{width:74%;height:74%;object-fit:contain;display:block}.portal-logo-frame.is-large .portal-logo-image{width:82%;height:82%}.portal-logo-ring,.portal-logo-link{stroke:color-mix(in srgb,var(--ps-modal-selected-border) 82%,var(--ps-page-foreground));stroke-linecap:round;stroke-linejoin:round}.portal-logo-ring{stroke-width:3;opacity:.46}.portal-logo-link{stroke-width:2.5;opacity:.9}.portal-logo-core{fill:var(--ps-highlight-background)}.portal-logo-node{stroke:color-mix(in srgb,var(--ps-page-background) 70%,transparent);stroke-width:1.5}.portal-logo-node-a,.portal-logo-node-d{fill:#7ee787}.portal-logo-node-b,.portal-logo-node-e{fill:#58a6ff}.portal-logo-node-c,.portal-logo-node-f{fill:#d2a8ff}.portal-header-user{display:inline-flex;align-items:flex-start;gap:10px;align-self:start}.portal-header-meta{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px;min-width:0}.portal-header-version{display:inline-flex;align-items:center;padding:3px 8px;border:1px solid color-mix(in srgb,var(--ps-border) 48%,transparent);border-radius:999px;background:color-mix(in srgb,var(--ps-surface) 90%,transparent);color:var(--ps-muted);font-size:.72rem;line-height:1;white-space:nowrap}.portal-header-status{max-width:min(44rem,42vw);color:var(--ps-muted);font-size:.72rem;line-height:1;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.portal-mobile-status{display:none}.portal-mobile-status-text{flex:1;min-width:0;color:var(--ps-foreground);font-size:.9rem;line-height:1.2;overflow-wrap:anywhere}.portal-mobile-status-dismiss{flex:none;appearance:none;border:none;background:transparent;color:var(--ps-muted);font-size:.9rem;line-height:1;padding:4px 6px;cursor:pointer;border-radius:6px}.portal-mobile-status-dismiss:hover{color:var(--ps-foreground);background:color-mix(in srgb,var(--ps-surface) 80%,transparent)}.portal-header-identity,.portal-header-identity-stack{min-width:0}.portal-header-identity-stack{display:flex;flex-direction:column;align-items:flex-start;gap:1px;text-align:left}.portal-header-name,.portal-header-email,.portal-header-identity{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.portal-header-name{color:var(--ps-foreground);font-size:.9rem;font-weight:700;line-height:1.05}.portal-header-email{color:var(--ps-muted);font-size:.78rem;line-height:1.05}.portal-header-identity{color:var(--ps-foreground);font-size:.84rem}.portal-header-identity.is-muted{color:var(--ps-muted)}.portal-main{flex:1;min-height:0;padding:10px;overflow:hidden}.ps-web-shell{min-height:0;height:100%;display:flex;flex-direction:column;gap:8px;overflow:hidden}.ps-toolbar{display:flex;align-items:center;justify-content:space-between;gap:6px}.ps-toolbar.is-mobile{flex-direction:column;align-items:stretch;justify-content:flex-start}.ps-toolbar-row{display:flex;align-items:center;gap:6px;min-width:0}.ps-toolbar-row-actions{display:flex;align-items:center;gap:6px;min-width:0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none}.ps-toolbar-row-actions::-webkit-scrollbar{display:none}.ps-toolbar-row-primary,.ps-toolbar-row-secondary{min-width:0}.ps-toolbar-row-secondary{justify-content:space-between}.ps-toolbar-actions{display:flex;flex-wrap:wrap;gap:6px;min-width:0}.ps-toolbar-status{flex:0 1 auto;color:var(--ps-muted);font-size:10px;line-height:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ps-toolbar-button,.ps-mini-button,.ps-modal-button,.ps-mobile-nav-button,.ps-tab,.ps-filter-option,.ps-modal-close{padding:6px 10px;min-height:34px;font-size:.92rem;line-height:1}.ps-workspace{flex:1;min-height:0;overflow:hidden}.ps-workspace>*{min-height:0}.ps-workspace-full{min-height:0;height:100%;display:flex}.ps-workspace-full>.ps-panel{flex:1;min-height:0}.ps-workspace-grid{display:grid;grid-template-rows:minmax(0,1fr);gap:8px;min-width:0;width:100%;max-width:100%;min-height:0;height:100%;overflow:hidden}.ps-workspace-column{display:grid;gap:8px;min-width:0;width:100%;max-width:100%;min-height:0;overflow:hidden}.ps-workspace-main-grid{display:grid;grid-template-rows:minmax(0,1fr);gap:8px;min-width:0;width:100%;max-width:100%;min-height:0;height:100%;overflow:hidden}.ps-workspace-pane-slot{min-width:0;width:100%;max-width:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden}.ps-workspace-pane-slot>.ps-panel{flex:1;min-width:0;width:100%;max-width:100%;min-height:0}.ps-chat-focus-shell{min-height:0;min-width:0;width:100%;max-width:100%;height:100%;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;overflow:hidden}.ps-chat-focus-rail{display:flex;align-items:center;gap:6px;padding:4px 6px;border:1px solid color-mix(in srgb,var(--ps-border) 42%,transparent);border-radius:var(--ps-radius-sm);background:color-mix(in srgb,var(--ps-surface) 94%,transparent)}.ps-chat-focus-button{flex:0 0 auto}.ps-chat-focus-status{margin-left:auto;color:var(--ps-muted);font-size:.72rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ps-chat-focus-body{position:relative;min-height:0;min-width:0;display:flex;overflow:hidden}.ps-chat-focus-body>.ps-panel{flex:1;min-height:0;min-width:0}.ps-chat-focus-body .ps-panel-body,.ps-chat-focus-body .ps-scroll-panel{min-width:0}.ps-chat-focus-body .ps-line,.ps-chat-focus-body .ps-chat-card-line{overflow-wrap:anywhere}.ps-chat-focus-overlay{position:absolute;top:10px;bottom:10px;width:min(34rem,calc(100% - 20px));max-width:min(34rem,42%);min-width:0;z-index:6;pointer-events:none;overflow:hidden}.ps-chat-focus-overlay.is-left{left:10px}.ps-chat-focus-overlay.is-right{right:10px}.ps-chat-focus-overlay>.ps-panel{height:100%;min-width:0;max-width:100%;pointer-events:auto;background:linear-gradient(color-mix(in srgb,var(--ps-surface) 96%,transparent),color-mix(in srgb,var(--ps-surface) 96%,transparent)),var(--ps-page-background);box-shadow:0 20px 48px #00000057,inset 0 0 0 1px color-mix(in srgb,var(--ps-page-background) 35%,transparent)}.ps-mobile-workspace{min-height:0;min-width:0;width:100%;max-width:100%;height:100%;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;overflow:hidden}.ps-mobile-session-pane{max-height:min(14vh,96px)}.ps-mobile-session-pane .ps-action-list{min-height:0}.ps-mobile-session-pane .ps-action-list .ps-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;overflow-wrap:normal;word-break:normal}.ps-mobile-session-collapsed .ps-panel-body{flex:0 0 auto}.ps-mobile-session-summary{padding:0 10px 10px;color:var(--ps-muted);font-size:var(--ps-font-size-dense);line-height:var(--ps-line-height-dense)}.ps-mobile-chat-pane{min-height:0;min-width:0;width:100%;max-width:100%;display:flex;flex-direction:column;overflow:hidden}.ps-mobile-chat-pane>.ps-panel{flex:1;min-width:0;width:100%;max-width:100%;min-height:0}.ps-mobile-pane-fill{min-height:0;height:100%;display:flex;flex-direction:column;overflow:hidden}.ps-mobile-pane-fill>.ps-panel{flex:1;min-height:0}.ps-column-resizer{align-self:stretch;width:16px;min-height:0;padding:0;border:0;border-radius:999px;background:transparent;display:grid;place-items:center;cursor:col-resize;position:relative}.ps-row-resizer{justify-self:stretch;height:16px;min-width:0;padding:0;border:0;border-radius:999px;background:transparent;display:grid;place-items:center;cursor:row-resize;position:relative}.ps-column-resizer:before{content:"";position:absolute;top:10px;bottom:10px;left:50%;width:1px;transform:translate(-50%);background:color-mix(in srgb,var(--ps-border) 52%,transparent)}.ps-row-resizer:before{content:"";position:absolute;left:10px;right:10px;top:50%;height:1px;transform:translateY(-50%);background:color-mix(in srgb,var(--ps-border) 52%,transparent)}.ps-column-resizer-handle{position:relative;z-index:1;display:grid;gap:4px;padding:10px 4px;border-radius:999px;border:1px solid color-mix(in srgb,var(--ps-modal-border) 78%,transparent);background:color-mix(in srgb,var(--ps-surface) 94%,transparent);box-shadow:0 6px 20px #0000002e}.ps-row-resizer-handle{position:relative;z-index:1;display:flex;gap:4px;padding:4px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--ps-modal-border) 78%,transparent);background:color-mix(in srgb,var(--ps-surface) 94%,transparent);box-shadow:0 6px 20px #0000002e}.ps-column-resizer-dot,.ps-row-resizer-dot{width:3px;height:3px;border-radius:999px;background:color-mix(in srgb,var(--ps-modal-selected-border) 84%,var(--ps-page-foreground))}.ps-column-resizer:hover .ps-column-resizer-handle,.ps-column-resizer.is-dragging .ps-column-resizer-handle,.ps-column-resizer:focus-visible .ps-column-resizer-handle{border-color:color-mix(in srgb,var(--ps-modal-selected-border) 72%,var(--ps-modal-border));background:color-mix(in srgb,var(--ps-modal-selected-background) 72%,var(--ps-surface))}.ps-row-resizer:hover .ps-row-resizer-handle,.ps-row-resizer.is-dragging .ps-row-resizer-handle,.ps-row-resizer:focus-visible .ps-row-resizer-handle{border-color:color-mix(in srgb,var(--ps-modal-selected-border) 72%,var(--ps-modal-border));background:color-mix(in srgb,var(--ps-modal-selected-background) 72%,var(--ps-surface))}.ps-column-resizer:focus-visible{outline:none}.ps-row-resizer:focus-visible{outline:none}body.is-resizing-pane-x,body.is-resizing-pane-x *{cursor:col-resize!important;-webkit-user-select:none!important;user-select:none!important}body.is-resizing-pane-y,body.is-resizing-pane-y *{cursor:row-resize!important;-webkit-user-select:none!important;user-select:none!important}.ps-panel{position:relative;min-height:0;display:flex;flex-direction:column;overflow:hidden;border:2px solid var(--ps-panel-accent, var(--ps-border));border-radius:var(--ps-radius-sm);background:color-mix(in srgb,var(--ps-surface) 96%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ps-page-background) 35%,transparent)}.ps-panel.is-focused{box-shadow:0 0 0 1px color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 40%,transparent),inset 0 0 0 1px color-mix(in srgb,var(--ps-page-background) 35%,transparent)}.ps-panel-header{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:30px;padding:4px 10px;border-bottom:1px solid color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 28%,transparent);background:linear-gradient(180deg,color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 16%,var(--ps-surface)),color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 10%,var(--ps-surface)));margin-bottom:0}.ps-panel-title{display:inline-flex;align-items:center;gap:4px;padding:0;background:transparent;font-size:.94rem;font-weight:700;line-height:1.02}.ps-panel-header-right{display:inline-flex;align-items:center;gap:8px;min-width:0;margin-left:auto}.ps-panel-title-right{display:inline-flex;align-items:center;gap:0;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.82rem;line-height:1.35}.ps-panel-actions{display:inline-flex;align-items:center;flex-wrap:wrap;gap:4px;padding-left:0;background:transparent}.ps-panel-body,.ps-scroll-panel{flex:1;min-width:0;width:100%;max-width:100%;min-height:0}.ps-panel-body{display:flex;flex-direction:column;min-height:0;padding-top:2px}.ps-scroll-panel{overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y;scrollbar-gutter:stable;padding:6px 10px 10px;font-size:var(--ps-font-size-dense);line-height:var(--ps-line-height-dense)}.ps-panel-sticky{flex:0 0 auto;padding:6px 10px 0;border-bottom:1px solid color-mix(in srgb,var(--ps-border) 35%,transparent);font-size:var(--ps-font-size-dense);line-height:var(--ps-line-height-dense)}.ps-panel-sticky.is-scroll-sync{overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;touch-action:pan-x;scrollbar-gutter:stable both-edges}.ps-panel-bottom-sticky{flex:0 0 auto;max-height:34%;overflow:auto;padding:4px 10px 8px;border-top:1px solid color-mix(in srgb,var(--ps-border) 45%,transparent);background:color-mix(in srgb,var(--ps-surface) 94%,#0f172a 6%);font-size:var(--ps-font-size-dense);line-height:var(--ps-line-height-dense);scrollbar-gutter:stable both-edges}.ps-line{min-height:1.16rem;white-space:pre-wrap;line-height:1.16rem;word-break:break-word}.ps-panel.has-preserved-sticky .ps-panel-sticky .ps-line,.ps-scroll-panel.is-preserve .ps-line{white-space:pre;word-break:normal;overflow-wrap:normal}.ps-scroll-panel.is-preserve .ps-line{min-width:max-content}.ps-chat-preserve-block{max-width:100%;overflow-x:auto}.ps-scroll-panel.is-wrapped .ps-line.ps-line-preserve,.ps-line-preserve{white-space:pre;word-break:normal;overflow-wrap:normal;min-width:max-content}.ps-chat-preserve-block.is-splash-mobile{display:none}@media(max-width:920px){.ps-chat-preserve-block.is-splash-desktop{display:none}.ps-chat-preserve-block.is-splash-mobile{display:block}.portal-header-status{display:none}.portal-mobile-status{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--ps-border);background:var(--ps-surface)}}@media(max-width:920px){.ps-chat-preserve-block{overflow-x:hidden}.ps-chat-preserve-block .ps-line-preserve,.ps-scroll-panel.is-wrapped .ps-line.ps-line-preserve{white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal;min-width:0}}.ps-sequence-turn-divider{width:100%;margin:4px 0;padding:2px 6px;border:0;--ps-sequence-divider: #4f6f96;border-top:1px solid color-mix(in srgb,var(--ps-sequence-divider) 82%,transparent);border-bottom:1px solid color-mix(in srgb,var(--ps-sequence-divider) 42%,transparent);border-radius:0;background:transparent;color:color-mix(in srgb,var(--ps-sequence-divider) 78%,var(--ps-text-muted));font:inherit;text-align:center;cursor:pointer}.ps-sequence-turn-divider:hover,.ps-sequence-turn-divider:focus-visible{color:var(--ps-text);background:color-mix(in srgb,var(--ps-surface-raised) 72%,transparent);outline:none}.ps-sequence-turn-caret{color:var(--ps-sequence-divider)}.ps-sequence-turn-rule{color:color-mix(in srgb,var(--ps-sequence-divider) 76%,var(--ps-text-muted))}.ps-sequence-turn-key{color:var(--ps-text-muted)}.ps-sequence-turn-model{color:var(--ps-text)}.ps-sequence-turn-tokens{color:var(--ps-success)}.ps-sequence-turn-duration{color:var(--ps-warning)}.ps-sequence-turn-details{margin:0 12px 8px;padding:8px 10px;border-left:2px solid var(--ps-accent);color:var(--ps-text-muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5}.ps-sequence-turn-detail-label{color:var(--ps-accent)}.ps-scroll-panel.is-wrapped .ps-line,.ps-modal-details .ps-line,.ps-filter-column .ps-line{white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;max-width:100%}.ps-chat-card{margin:8px 0;border:1px solid color-mix(in srgb,var(--ps-chat-card-accent, var(--ps-border)) 78%,transparent);border-radius:var(--ps-radius-sm);background:color-mix(in srgb,var(--ps-surface) 98%,transparent);overflow:hidden;width:auto;max-width:100%}.ps-chat-card-header{padding:8px 12px 6px;border-bottom:1px solid color-mix(in srgb,var(--ps-chat-card-accent, var(--ps-border)) 28%,transparent);background:color-mix(in srgb,var(--ps-chat-card-accent, var(--ps-surface)) 10%,transparent)}.ps-chat-card-body{padding:8px 12px 10px}.ps-chat-card-line{white-space:pre-wrap;word-break:break-word}.ps-chat-card-line+.ps-chat-card-line{margin-top:6px}.ps-chat-panel .ps-line,.ps-chat-panel .ps-chat-card-line{min-height:1.24rem;line-height:1.24rem}.ps-system-notice{margin:10px 0 12px;color:var(--ps-muted)}.ps-system-notice[open]{margin-bottom:14px}.ps-system-notice-summary{cursor:pointer;list-style:none;-webkit-user-select:none;user-select:none;opacity:.86;transition:opacity .12s ease}.ps-system-notice-summary:hover,.ps-system-notice[open] .ps-system-notice-summary{opacity:1}.ps-system-notice-summary::-webkit-details-marker{display:none}.ps-system-notice-summary-text{display:inline-block;white-space:pre-wrap}.ps-system-notice-summary:before{content:"▸ ";color:color-mix(in srgb,var(--ps-muted) 84%,transparent)}.ps-system-notice[open] .ps-system-notice-summary:before{content:"▾ "}.ps-system-notice-body{margin-top:8px;padding-left:12px;border-left:1px solid color-mix(in srgb,var(--ps-border) 42%,transparent);color:var(--ps-muted)}.ps-system-notice-body .ps-markdown-preview{gap:10px}.ps-system-notice-body .ps-md-paragraph,.ps-system-notice-body .ps-md-list,.ps-system-notice-body .ps-md-quote{color:var(--ps-muted)}.ps-chat-table-wrap{margin:8px auto 8px 0;border:1px solid color-mix(in srgb,var(--ps-border) 55%,transparent);border-radius:var(--ps-radius-sm);overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;width:fit-content;max-width:100%;scrollbar-width:thin;scrollbar-color:var(--ps-scrollbar-thumb) transparent}.ps-chat-table-wrap::-webkit-scrollbar,.ps-md-table-wrap::-webkit-scrollbar,.ps-md-code-pre::-webkit-scrollbar{width:var(--ps-scrollbar-size);height:var(--ps-scrollbar-size)}.ps-chat-table-wrap::-webkit-scrollbar-track,.ps-md-table-wrap::-webkit-scrollbar-track,.ps-md-code-pre::-webkit-scrollbar-track{background:transparent}.ps-chat-table-wrap::-webkit-scrollbar-thumb,.ps-md-table-wrap::-webkit-scrollbar-thumb,.ps-md-code-pre::-webkit-scrollbar-thumb{background:var(--ps-scrollbar-thumb);border:2px solid transparent;border-radius:999px;background-clip:padding-box;min-height:28px;min-width:28px}.ps-chat-table-wrap::-webkit-scrollbar-thumb:hover,.ps-md-table-wrap::-webkit-scrollbar-thumb:hover,.ps-md-code-pre::-webkit-scrollbar-thumb:hover{background:var(--ps-scrollbar-thumb-hover);border:2px solid transparent;background-clip:padding-box}.ps-chat-table-wrap.is-fit-width,.ps-md-table-wrap.is-fit-width{width:fit-content;max-width:100%}.ps-chat-table{width:max-content;border-collapse:collapse;table-layout:auto}.ps-chat-table.is-fit-width,.ps-md-table.is-fit-width{width:fit-content;max-width:100%;min-width:var(--ps-table-min-width, 0px);table-layout:auto}.ps-chat-table th,.ps-chat-table td{padding:6px 8px;border-right:1px solid color-mix(in srgb,var(--ps-border) 34%,transparent);border-bottom:1px solid color-mix(in srgb,var(--ps-border) 34%,transparent);white-space:pre-wrap;word-break:break-word;vertical-align:top}.ps-chat-table.is-fit-width th,.ps-chat-table.is-fit-width td,.ps-md-table.is-fit-width th,.ps-md-table.is-fit-width td{min-width:0;white-space:normal;word-break:break-word;overflow-wrap:anywhere}.ps-chat-table-wrap.is-fit-width.has-flex-column,.ps-md-table-wrap.is-fit-width.has-flex-column{width:100%;max-width:100%}.ps-chat-table.is-fit-width.has-flex-column,.ps-md-table.is-fit-width.has-flex-column{width:max(100%,var(--ps-table-min-width, 0px));min-width:var(--ps-table-min-width, 0px);table-layout:fixed}.ps-chat-table.is-fit-width.has-flex-column th,.ps-chat-table.is-fit-width.has-flex-column td,.ps-md-table.is-fit-width.has-flex-column th,.ps-md-table.is-fit-width.has-flex-column td{overflow-wrap:normal;word-break:normal}.ps-chat-table.is-fit-width.has-flex-column th.is-flex-column,.ps-chat-table.is-fit-width.has-flex-column td.is-flex-column,.ps-md-table.is-fit-width.has-flex-column th.is-flex-column,.ps-md-table.is-fit-width.has-flex-column td.is-flex-column{white-space:normal;overflow-wrap:anywhere;word-break:break-word}.ps-chat-table th:last-child,.ps-chat-table td:last-child{border-right:0}.ps-chat-table tbody tr:last-child td{border-bottom:0}.ps-chat-table th{text-align:left;font-weight:700;color:var(--ps-foreground);background:color-mix(in srgb,var(--ps-highlight-background) 10%,var(--ps-surface))}.ps-table-cell-value{min-width:0}.ps-chat-code-block{margin:8px 0;width:auto;max-width:100%}.ps-action-list{display:flex;flex-direction:column;flex:1 1 auto;gap:4px;padding:6px 10px 10px;min-width:0;min-height:0;width:100%;max-width:100%;overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y;scrollbar-gutter:stable both-edges;font-size:var(--ps-font-size-dense);line-height:var(--ps-line-height-dense)}.ps-session-pane.ps-chat-focus-pane .ps-action-list.ps-session-list{overflow-x:auto;touch-action:pan-x pan-y;padding-bottom:calc(6px + var(--ps-scrollbar-size))}.ps-session-pane.ps-chat-focus-pane .ps-session-list-button{width:max-content;min-width:100%;max-width:none;overflow:visible}.ps-session-pane.ps-chat-focus-pane .ps-session-row-content{display:inline-block;min-width:max-content;overflow:visible;max-width:none}.ps-session-pane.ps-chat-focus-pane .ps-action-list .ps-line{max-width:none;min-width:0;white-space:nowrap;overflow:visible;text-overflow:clip}.ps-list-button{width:100%;min-width:0;max-width:100%;overflow:hidden;text-align:left;padding:7px 9px;flex:0 0 auto}.ps-action-list .ps-line{display:block;min-width:0;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ps-session-pane .ps-panel-body{padding:2px 8px 8px}.ps-session-pane .ps-session-list{gap:1px;padding:2px 0 6px}.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-list{overflow-x:auto;overflow-y:auto;touch-action:pan-x pan-y;padding-bottom:calc(6px + var(--ps-scrollbar-size))}.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-list-button{width:max-content;min-width:100%;max-width:none;overflow:visible}.ps-session-pane:not(.ps-mobile-session-pane):not(.ps-chat-focus-pane) .ps-session-list-button+.ps-session-list-button{border-top:1px solid color-mix(in srgb,var(--ps-border) 22%,transparent)}.ps-session-pane .ps-session-list-button{display:block;border:0;border-radius:2px;background:transparent;box-shadow:none;color:inherit;padding:3px 8px;min-height:1.55rem;white-space:normal;flex:0 0 auto}.ps-session-pane .ps-session-list-button:hover{transform:none;border-color:transparent;background:color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 7%,transparent)}.ps-session-pane .ps-session-list-button.is-selected{border-color:transparent;background:color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 15%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 80%,transparent),inset 3px 0 color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 90%,transparent);color:inherit}.ps-session-pane .ps-session-list-button.is-multiselected{background:color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 60%,transparent),inset 3px 0 color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 95%,transparent)}.ps-session-pane .ps-session-list-button.is-pinned{background:color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 5%,transparent)}.ps-session-pane .ps-session-list-button:focus-visible{outline:none;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 80%,transparent),inset 3px 0 color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 90%,transparent),0 0 0 1px color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 35%,transparent)}.ps-session-row-content{display:block;min-width:0;max-width:100%;overflow:visible}.ps-session-row-line{display:flex;align-items:baseline;gap:8px;min-width:0;max-width:100%}.ps-session-row-title{min-width:0;flex:1 1 auto;line-height:1.28}.ps-session-row-ctx{flex:0 0 auto;margin-left:auto;padding-inline-start:8px;font-variant-numeric:tabular-nums;white-space:nowrap;opacity:.96}.ps-session-row-detail{margin-top:3px;padding-inline-start:20px;line-height:1.24;font-size:.94em;opacity:.9}.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-row-content,.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-row-line,.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-row-title,.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-row-detail{width:max-content;min-width:max-content;max-width:none}.ps-session-pane:not(.ps-mobile-session-pane) .ps-session-row-title{white-space:nowrap;overflow:visible}.ps-session-pane .ps-action-list .ps-line{white-space:nowrap;overflow:visible;text-overflow:clip;overflow-wrap:normal;word-break:normal;max-width:none}.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-list{overflow-x:hidden;padding-bottom:6px}.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-list-button{width:100%;min-width:0;max-width:100%;overflow:hidden}.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-row-content,.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-row-line,.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-row-title,.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-row-detail{width:auto;min-width:0;max-width:100%}.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-session-row-title,.ps-session-pane.ps-mobile-session-pane .ps-session-row-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;white-space:normal}.ps-workspace-main-grid.is-session-wrap .ps-session-pane .ps-action-list .ps-line{white-space:normal;overflow:hidden;overflow-wrap:anywhere;word-break:break-word;max-width:100%}.ps-session-pane.ps-mobile-session-pane .ps-action-list .ps-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;overflow-wrap:normal;word-break:normal}.ps-list-button.is-selected,.ps-toolbar-button.is-active,.ps-mini-button.is-active,.ps-tab.is-active,.ps-mobile-nav-button.is-active,.ps-filter-option.is-selected{border-color:var(--ps-modal-selected-border);background:var(--ps-modal-selected-background);color:var(--ps-modal-selected-foreground)}.ps-empty-state{color:var(--ps-muted);padding:6px 0}.ps-nav-error-state{display:flex;flex-direction:column;align-items:flex-start;gap:10px;padding:18px 12px}.ps-nav-error-message{max-width:42em;line-height:1.5}.ps-tab-row{display:flex;flex-wrap:wrap;gap:4px;padding:2px 10px 4px;align-items:center}.ps-panel .ps-mini-button,.ps-panel .ps-tab{padding:4px 8px;min-height:30px}.ps-files-grid{flex:1;min-height:0;display:grid;grid-template-rows:minmax(180px,.9fr) minmax(220px,1.3fr);gap:8px;padding:0 10px 10px}.ps-scroll-panel.is-preview,.ps-markdown-scroll{padding-top:10px}.ps-markdown-preview{display:flex;flex-direction:column;gap:14px;padding-right:4px;line-height:1.55}.ps-binary-preview-card{display:flex;flex-direction:column;gap:10px;margin:10px 10px 12px;padding:18px 18px 20px;border:1px solid color-mix(in srgb,var(--ps-border) 52%,transparent);border-radius:calc(var(--ps-radius-md) + 2px);background:radial-gradient(circle at top right,color-mix(in srgb,var(--ps-highlight-background) 18%,transparent),transparent 42%),linear-gradient(180deg,color-mix(in srgb,var(--ps-surface) 96%,transparent),color-mix(in srgb,var(--ps-background) 96%,transparent));box-shadow:inset 0 1px color-mix(in srgb,white 5%,transparent)}.ps-binary-preview-kicker{font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--ps-muted)}.ps-binary-preview-title{margin:0;font-size:1.08rem;line-height:1.25;font-weight:800;color:var(--ps-foreground);word-break:break-word}.ps-binary-preview-meta,.ps-binary-preview-time{color:color-mix(in srgb,var(--ps-foreground) 76%,var(--ps-muted));font-size:.9rem;line-height:1.45;word-break:break-word}.ps-binary-preview-copy{margin:4px 0 0;color:var(--ps-foreground);line-height:1.6}.ps-md-heading{margin:0;font-weight:800;line-height:1.2;color:var(--ps-foreground)}.ps-md-heading.is-h1{font-size:1.45rem}.ps-md-heading.is-h2{font-size:1.28rem}.ps-md-heading.is-h3{font-size:1.12rem}.ps-md-heading.is-h4,.ps-md-heading.is-h5,.ps-md-heading.is-h6{font-size:1rem}.ps-md-paragraph,.ps-md-quote,.ps-md-list{margin:0}.ps-md-list{padding-left:22px;display:flex;flex-direction:column;gap:6px}.ps-md-list-item{line-height:1.5}.ps-md-quote{margin:0;padding:10px 14px;border-left:3px solid color-mix(in srgb,var(--ps-modal-selected-border) 78%,transparent);background:color-mix(in srgb,var(--ps-highlight-background) 8%,transparent);color:color-mix(in srgb,var(--ps-foreground) 88%,var(--ps-muted))}.ps-md-inline-code{padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--ps-highlight-background) 12%,var(--ps-surface));border:1px solid color-mix(in srgb,var(--ps-border) 35%,transparent);font-size:.94em}.ps-md-code-block{border:1px solid color-mix(in srgb,var(--ps-border) 45%,transparent);border-radius:var(--ps-radius-sm);overflow:hidden;background:color-mix(in srgb,var(--ps-background) 96%,black 4%)}.ps-md-code-header{padding:7px 12px;font-size:.82rem;text-transform:uppercase;letter-spacing:.08em;color:var(--ps-muted);border-bottom:1px solid color-mix(in srgb,var(--ps-border) 32%,transparent);background:color-mix(in srgb,var(--ps-surface) 92%,transparent)}.ps-md-code-pre{margin:0;padding:14px 16px;overflow:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y;line-height:1.55;font-size:.95rem;white-space:pre;max-width:100%;scrollbar-width:thin;scrollbar-color:var(--ps-scrollbar-thumb) transparent}.ps-md-code-pre code{font:inherit;color:#9fe3ff}.ps-md-table-wrap{overflow-x:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y;border:1px solid color-mix(in srgb,var(--ps-border) 40%,transparent);border-radius:var(--ps-radius-sm);max-width:100%;scrollbar-width:thin;scrollbar-color:var(--ps-scrollbar-thumb) transparent}.ps-md-table{width:100%;border-collapse:collapse}.ps-md-table th,.ps-md-table td{padding:9px 12px;border-bottom:1px solid color-mix(in srgb,var(--ps-border) 25%,transparent);text-align:left;vertical-align:top;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere}.ps-md-table th{background:color-mix(in srgb,var(--ps-highlight-background) 10%,var(--ps-surface));font-weight:700}.ps-md-table tbody tr:last-child td{border-bottom:0}.ps-md-link{text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere;word-break:break-word}.ps-chat-panel .ps-panel-header{padding-bottom:8px}.ps-chat-panel .ps-panel-body{padding-top:2px}.ps-chat-panel .ps-scroll-panel{padding-top:6px}.ps-chat-panel .ps-scroll-panel.is-scrolled-down{-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 18px,#000 100%);mask-image:linear-gradient(to bottom,transparent 0,#000 18px,#000 100%)}.ps-chat-panel .ps-scroll-panel.is-scrolled-up{-webkit-mask-image:linear-gradient(to bottom,#000 0,#000 calc(100% - 18px),transparent 100%);mask-image:linear-gradient(to bottom,#000 0,#000 calc(100% - 18px),transparent 100%)}.ps-chat-panel .ps-scroll-panel.is-scrolled-down.is-scrolled-up{-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 18px,#000 calc(100% - 18px),transparent 100%);mask-image:linear-gradient(to bottom,transparent 0,#000 18px,#000 calc(100% - 18px),transparent 100%)}.ps-chat-panel .ps-panel-bottom-sticky{box-shadow:0 -10px 22px color-mix(in srgb,#000 24%,transparent)}.ps-status-strip{display:flex;justify-content:space-between;gap:10px;padding:0 2px;color:var(--ps-muted);font-size:9px}.ps-status-left,.ps-status-right{min-width:0;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.ps-chat-composer{display:flex;flex-direction:column;gap:4px;padding:8px 10px 10px;border-top:1px solid color-mix(in srgb,var(--ps-panel-accent, var(--ps-border)) 32%,transparent);background:color-mix(in srgb,var(--ps-surface) 98%,transparent);isolation:isolate}.ps-prompt-shell{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:center;padding:0}.ps-prompt-shell.is-mobile{grid-template-columns:auto minmax(0,1fr) auto;gap:8px}.ps-prompt-actions{display:inline-flex;align-items:center;gap:8px}.ps-hidden-file-input{display:none}.ps-prompt-label{padding:0 4px 0 2px;color:var(--ps-muted);text-transform:uppercase;font-size:10px;letter-spacing:.08em;align-self:center}.ps-prompt-shell.is-mobile .ps-prompt-label{padding:0 2px 0 0}.ps-prompt-input,.ps-modal-input{width:100%;min-height:40px;resize:vertical;border:1px solid color-mix(in srgb,var(--ps-border) 60%,transparent);border-radius:var(--ps-radius-sm);background:var(--ps-background);background-clip:padding-box;color:var(--ps-foreground);caret-color:var(--ps-foreground);padding:8px 10px;outline:none;font-size:var(--ps-font-size-dense);line-height:var(--ps-line-height-dense)}.ps-prompt-input::placeholder,.ps-modal-input::placeholder{color:color-mix(in srgb,var(--ps-muted) 74%,transparent);opacity:1}.ps-prompt-shell.is-mobile .ps-prompt-input{font-size:16px;line-height:1.25}.ps-prompt-input:focus,.ps-modal-input:focus{border-color:var(--ps-modal-selected-border);box-shadow:0 0 0 3px color-mix(in srgb,var(--ps-modal-selected-border) 20%,transparent)}.ps-prompt-shell.is-drag-over .ps-prompt-input{border-color:var(--ps-modal-selected-border);background:color-mix(in srgb,var(--ps-modal-selected-background) 16%,var(--ps-background));box-shadow:0 0 0 3px color-mix(in srgb,var(--ps-modal-selected-border) 18%,transparent)}.ps-prompt-attachments{grid-column:1 / -1;display:flex;flex-wrap:wrap;gap:8px;padding:6px 0 2px}.ps-attach-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 6px;border:1px solid color-mix(in srgb,var(--ps-border) 60%,transparent);border-radius:var(--ps-radius-sm);background:color-mix(in srgb,var(--ps-surface) 94%,transparent)}.ps-attach-thumb{width:40px;height:40px;object-fit:cover;border-radius:4px;flex:0 0 auto}.ps-attach-thumb.is-placeholder{display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--ps-border) 30%,transparent)}.ps-attach-size{color:var(--ps-muted);font-size:11px}.ps-attach-remove{flex:0 0 auto;border:none;background:none;color:var(--ps-muted);padding:2px 4px}.ps-attach-remove:hover{color:var(--ps-foreground)}.ps-attach-button{min-height:40px}.ps-chat-image-strip{display:flex;flex-wrap:wrap;gap:8px;margin:6px 0 10px}.ps-chat-image-thumb{width:96px;height:96px;object-fit:cover;border:1px solid color-mix(in srgb,var(--ps-border) 60%,transparent);border-radius:var(--ps-radius-sm);cursor:pointer}.ps-chat-image-thumb:hover{border-color:var(--ps-modal-selected-border)}.ps-chat-image-thumb.is-pending{display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--ps-border) 24%,transparent);color:var(--ps-muted);font-size:24px;cursor:default}.ps-chat-image-thumb.is-pending.is-error{color:var(--ps-foreground)}.ps-binary-preview-image{display:block;max-width:100%;max-height:60vh;margin-top:12px;border:1px solid color-mix(in srgb,var(--ps-border) 60%,transparent);border-radius:var(--ps-radius-sm);cursor:pointer}.ps-send-button{min-height:40px;padding:0 12px}.ps-stop-button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;min-width:40px;padding:0;font-size:15px;line-height:1;border:1px solid color-mix(in srgb,#e5534b 55%,var(--ps-modal-border));border-radius:var(--ps-radius-sm);background:color-mix(in srgb,#e5534b 16%,var(--ps-surface));color:#ff8a80;transition:background .12s ease,border-color .12s ease,transform .12s ease}.ps-stop-button:hover:not(:disabled){background:color-mix(in srgb,#e5534b 30%,var(--ps-surface));border-color:#e5534b}.ps-stop-button:disabled{cursor:default}.ps-stop-button.is-stopping{animation:ps-stop-pulse 1s ease-in-out infinite}@keyframes ps-stop-pulse{50%{opacity:.35}}.ps-send-button.is-inline{min-height:40px;min-width:40px;padding:0;border-radius:var(--ps-radius-sm);font-size:18px;line-height:1;display:inline-flex;align-items:center;justify-content:center}.ps-mobile-nav{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.ps-modal-backdrop{position:fixed;inset:0;background:var(--ps-modal-backdrop);display:flex;align-items:center;justify-content:center;padding:16px;z-index:50}.ps-modal{width:min(900px,100%);max-height:min(86vh,900px);overflow:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y;scrollbar-gutter:stable both-edges;border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-md);background:var(--ps-modal-background);color:var(--ps-modal-foreground);padding:18px;box-shadow:0 30px 80px #0006}.ps-scroll-panel::-webkit-scrollbar,.ps-action-list::-webkit-scrollbar,.ps-panel-sticky.is-scroll-sync::-webkit-scrollbar,.ps-modal::-webkit-scrollbar{width:var(--ps-scrollbar-size);height:var(--ps-scrollbar-size)}.ps-scroll-panel::-webkit-scrollbar-track,.ps-action-list::-webkit-scrollbar-track,.ps-panel-sticky.is-scroll-sync::-webkit-scrollbar-track,.ps-modal::-webkit-scrollbar-track{background:transparent}.ps-scroll-panel::-webkit-scrollbar-thumb,.ps-action-list::-webkit-scrollbar-thumb,.ps-panel-sticky.is-scroll-sync::-webkit-scrollbar-thumb,.ps-modal::-webkit-scrollbar-thumb{background:var(--ps-scrollbar-thumb);border:2px solid transparent;border-radius:999px;background-clip:padding-box;min-height:28px}.ps-scroll-panel::-webkit-scrollbar-thumb:hover,.ps-action-list::-webkit-scrollbar-thumb:hover,.ps-panel-sticky.is-scroll-sync::-webkit-scrollbar-thumb:hover,.ps-modal::-webkit-scrollbar-thumb:hover{background:var(--ps-scrollbar-thumb-hover);border:2px solid transparent;background-clip:padding-box}.ps-scroll-panel::-webkit-scrollbar-corner,.ps-action-list::-webkit-scrollbar-corner,.ps-panel-sticky.is-scroll-sync::-webkit-scrollbar-corner,.ps-modal::-webkit-scrollbar-corner{background:transparent}.ps-modal.is-narrow{width:min(560px,100%)}.ps-modal.is-wide{width:min(980px,100%)}.ps-modal.is-theme-picker{max-height:min(57vh,600px)}.ps-modal-header,.ps-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:12px}.ps-modal-title,.ps-modal-details-title,.ps-filter-title{font-size:16px;font-weight:700}.ps-modal-grid{display:grid;grid-template-columns:minmax(280px,1fr) minmax(260px,.9fr);gap:16px;margin:16px 0}.ps-modal-list,.ps-modal-details{display:flex;flex-direction:column;gap:10px}.ps-modal-list-heading-line,.ps-modal-list-line,.ps-modal-detail-line{display:block;width:100%;text-align:left}.ps-modal-list-button.is-hanging .ps-modal-list-line{padding-left:1.5rem;text-indent:-1.15rem}.ps-modal-list-button.is-disabled{opacity:.55;cursor:not-allowed}.ps-filter-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin:18px 0}.ps-filter-column{display:flex;flex-direction:column;gap:10px}.ps-list-button,.ps-filter-option{justify-content:flex-start;text-align:left}.ps-modal-close{color:var(--ps-muted)}.portal-primary-button:hover,.portal-secondary-button:hover,.ps-toolbar-button:hover,.ps-mini-button:hover,.ps-send-button:hover,.ps-modal-button:hover,.ps-mobile-nav-button:hover,.ps-tab:hover,.ps-filter-option:hover,.ps-list-button:hover,.ps-modal-close:hover,.ps-column-resizer:hover{transform:translateY(-1px);border-color:color-mix(in srgb,var(--ps-modal-selected-border) 70%,var(--ps-modal-border))}@media(max-width:920px){:root{--ps-font-size-base: 13px;--ps-font-size-dense: 12px}.portal-main{padding:8px}.portal-header{grid-template-columns:minmax(0,1fr) auto;align-items:start;padding:8px 10px}.portal-header-user{gap:8px;align-self:start}.ps-toolbar{align-items:stretch}.ps-toolbar.is-mobile{gap:6px}.ps-toolbar.is-mobile .ps-toolbar-row{flex-wrap:nowrap}.ps-toolbar.is-mobile .ps-icon-button{flex:0 0 auto;padding:8px 9px}.ps-toolbar.is-mobile .ps-toolbar-status{margin-left:auto;max-width:44%;min-width:0;text-align:right}.ps-toolbar-status{max-width:40%;font-size:9px}.ps-chat-focus-rail{flex-wrap:wrap}.ps-chat-focus-status{width:100%;margin-left:0}.ps-chat-focus-overlay{left:8px;right:8px;width:auto;max-width:none;min-width:0}.ps-chat-focus-shell .ps-chat-table-wrap,.ps-chat-focus-shell .ps-md-table-wrap{max-width:100%}.ps-chat-table-wrap.is-fit-width.has-flex-column,.ps-md-table-wrap.is-fit-width.has-flex-column{overflow-x:hidden}.ps-chat-table.is-fit-width.has-flex-column,.ps-md-table.is-fit-width.has-flex-column{display:block;width:100%;min-width:0;table-layout:auto}.ps-chat-table.is-fit-width.has-flex-column colgroup,.ps-chat-table.is-fit-width.has-flex-column thead,.ps-md-table.is-fit-width.has-flex-column colgroup,.ps-md-table.is-fit-width.has-flex-column thead{display:none}.ps-chat-table.is-fit-width.has-flex-column tbody,.ps-md-table.is-fit-width.has-flex-column tbody{display:flex;flex-direction:column}.ps-chat-table.is-fit-width.has-flex-column tr,.ps-md-table.is-fit-width.has-flex-column tr{display:grid;gap:0;border-bottom:1px solid color-mix(in srgb,var(--ps-border) 34%,transparent)}.ps-chat-table.is-fit-width.has-flex-column tr:last-child,.ps-md-table.is-fit-width.has-flex-column tr:last-child{border-bottom:0}.ps-chat-table.is-fit-width.has-flex-column td,.ps-md-table.is-fit-width.has-flex-column td{display:grid;grid-template-columns:minmax(7ch,.34fr) minmax(0,1fr);gap:8px;align-items:start;padding:6px 8px;border-right:0;border-bottom:0;min-width:0;overflow-wrap:anywhere;word-break:break-word}.ps-md-table.is-fit-width.has-flex-column td{padding:7px 10px}.ps-chat-table.is-fit-width.has-flex-column td[data-label]:before,.ps-md-table.is-fit-width.has-flex-column td[data-label]:before{content:attr(data-label);min-width:0;color:var(--ps-muted);font-weight:700;overflow-wrap:anywhere;word-break:break-word}.ps-chat-table.is-fit-width.has-flex-column .ps-table-cell-value,.ps-md-table.is-fit-width.has-flex-column .ps-table-cell-value{min-width:0;overflow-wrap:anywhere;word-break:break-word}.ps-chat-composer{padding:4px 6px 6px}.ps-status-right{display:none}.ps-status-strip{justify-content:flex-start}.ps-modal-grid{grid-template-columns:1fr}.ps-files-grid{grid-template-rows:minmax(160px,1fr) minmax(260px,1.4fr)}.ps-panel-header{flex-wrap:nowrap}.ps-panel-title{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ps-panel-actions{flex:0 0 auto;flex-wrap:nowrap}.ps-column-resizer{display:none}.ps-mobile-session-pane{max-height:min(22vh,148px)}.ps-mobile-session-pane .ps-panel-body,.ps-mobile-session-pane .ps-action-list{min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y}.ps-modal{max-height:82svh;padding:12px}.ps-modal-header,.ps-modal-footer{flex-wrap:wrap;row-gap:8px}.ps-mobile-pane-fill>.ps-panel .ps-scroll-panel,.ps-mobile-pane-fill>.ps-panel .ps-panel-body{flex:1}}.ps-admin-console{display:flex;flex-direction:column;gap:16px;padding:18px 22px;flex:1;min-height:0;overflow:auto;background:var(--ps-panel-background, var(--ps-background));border:1px solid var(--ps-border);border-radius:6px;margin:8px}.ps-admin-console__header{display:flex;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid var(--ps-border);padding-bottom:8px}.ps-admin-console__header h2{margin:0;font-size:16px}.ps-admin-console__identity dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:0;font-size:13px}.ps-admin-console__identity dt{color:var(--ps-muted)}.ps-admin-console__identity dd{margin:0}.ps-admin-console__section{display:flex;flex-direction:column;gap:8px;border:1px solid var(--ps-border);border-radius:6px;padding:12px 14px}.ps-admin-console__section h3{margin:0;font-size:14px}.ps-admin-console__hint{margin:0;color:var(--ps-muted);font-size:12px}.ps-admin-console__status{margin:0;font-size:12px;color:var(--ps-muted)}.ps-admin-console__system-toggle{display:flex;align-items:center;gap:8px;margin:2px 0;font-size:12px;color:var(--ps-fg);cursor:pointer;-webkit-user-select:none;user-select:none}.ps-admin-console__system-toggle input{accent-color:var(--ps-accent, #4a9eff)}.ps-admin-console__status.is-error{color:var(--ps-error, #d33)}.ps-admin-console__error{border:1px solid var(--ps-error, #d33);background:color-mix(in srgb,var(--ps-error, #d33) 10%,transparent);color:var(--ps-error, #d33);padding:8px 10px;border-radius:4px;font-size:12px}.ps-admin-console__form{display:flex;flex-direction:column;gap:8px}.ps-admin-console__form input{font-family:var(--ps-font-mono, monospace);padding:6px 8px;border:1px solid var(--ps-border);border-radius:4px;background:var(--ps-background);color:var(--ps-foreground)}.ps-admin-console__actions{display:flex;gap:8px;flex-wrap:wrap}.portal-dev-banner{display:flex;align-items:center;justify-content:center;gap:8px;padding:6px 12px;border-radius:var(--ps-radius-sm);background:repeating-linear-gradient(-45deg,#eab30838,#eab30838 12px,#eab3081a 12px 24px);border:1px solid rgba(234,179,8,.55);color:var(--ps-foreground);font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-bottom:14px}.portal-dev-banner.is-fixed{border-radius:0;border-left:none;border-right:none;border-top:none;margin-bottom:0}.portal-dev-persona-list{display:flex;flex-direction:column;gap:8px;margin-top:20px}.portal-dev-persona-button{display:flex;align-items:center;gap:12px;width:100%;padding:10px 14px;border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-sm);background:color-mix(in srgb,var(--ps-surface) 94%,transparent);color:var(--ps-foreground);cursor:pointer;text-align:left;transition:background .12s ease,border-color .12s ease}.portal-dev-persona-button:hover{background:var(--ps-highlight-background);color:var(--ps-highlight-foreground)}.portal-dev-persona-initials{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:color-mix(in srgb,var(--ps-foreground) 12%,transparent);font-size:11px;font-weight:700;flex:none}.portal-dev-persona-name{flex:1;font-weight:600}.portal-dev-persona-role{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ps-muted)}.portal-dev-persona-role.is-admin{color:#f59e0b;font-weight:700}.ps-access-bar{display:flex;align-items:center;gap:10px;padding:4px 6px 8px;font-size:12px;color:var(--ps-muted)}.ps-access-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;border:1px solid var(--ps-modal-border);background:color-mix(in srgb,var(--ps-surface) 92%,transparent);font-weight:600;white-space:nowrap}.ps-access-owner{font-style:italic}.ps-access-readonly{color:#f59e0b;font-weight:600;text-transform:uppercase;letter-spacing:.06em;font-size:11px}.ps-access-spacer{flex:1}.ps-composer-readonly{padding:12px 14px;border:1px dashed var(--ps-modal-border);border-radius:var(--ps-radius-sm);color:var(--ps-muted);font-size:13px;line-height:1.5}.ps-share-overlay{position:fixed;inset:0;background:#00000073;display:flex;align-items:center;justify-content:center;z-index:1000;padding:24px}.ps-share-modal{width:min(440px,100%);height:min(540px,85vh);display:flex;flex-direction:column;overflow-y:auto;background:var(--ps-modal-background, var(--ps-surface));border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-md);box-shadow:0 28px 80px #0006;padding:18px;color:var(--ps-foreground)}.ps-share-modal-head{display:flex;align-items:center;justify-content:space-between;font-size:16px;font-weight:700;margin-bottom:12px}.ps-link-modal{width:min(480px,100%);max-height:80vh;overflow-y:auto;background:var(--ps-modal-background, var(--ps-surface));border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-md);box-shadow:0 28px 80px #0006;padding:18px;color:var(--ps-foreground)}.ps-link-row{display:flex;gap:8px;margin-top:12px}.ps-link-input{flex:1;min-width:0;font-family:var(--ps-mono, monospace);font-size:12px;padding:8px 10px;color:var(--ps-foreground);background:var(--ps-background);border:1px solid var(--ps-border);border-radius:var(--ps-radius-sm)}.ps-link-warn{margin-top:12px;padding:8px 10px;font-size:12px;color:var(--ps-foreground);background:color-mix(in srgb,var(--ps-warning, #d0a215) 16%,transparent);border:1px solid color-mix(in srgb,var(--ps-warning, #d0a215) 45%,transparent);border-radius:var(--ps-radius-sm)}.ps-share-section-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ps-muted);margin:14px 0 6px}.ps-manage-tabs{display:flex;gap:6px;margin-bottom:12px}.ps-manage-tab{flex:1 1 0;text-align:center;appearance:none;background:transparent;color:var(--ps-muted);font:inherit;font-size:13px;padding:8px 14px;cursor:pointer;border:1px solid transparent;border-radius:8px}.ps-manage-tab:hover{color:var(--ps-foreground)}.ps-manage-tab:focus{outline:none}.ps-manage-tab:focus-visible{outline:none;color:var(--ps-foreground);border-color:color-mix(in srgb,var(--ps-highlight-background) 45%,transparent)}.ps-manage-tab.is-active{color:var(--ps-foreground);font-weight:600;border-color:var(--ps-highlight-background);background:color-mix(in srgb,var(--ps-highlight-background) 14%,transparent)}.ps-manage-model-current{flex:1;font-family:var(--ps-mono, monospace);font-size:13px;color:var(--ps-foreground);overflow-wrap:anywhere}.ps-manage-apply-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding-top:12px;border-top:1px solid var(--ps-border)}.ps-mini-button.ps-manage-apply:disabled{background:transparent;color:var(--ps-muted);border-color:color-mix(in srgb,var(--ps-border) 60%,transparent);opacity:.5;cursor:not-allowed}.ps-share-radio{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--ps-radius-sm);cursor:pointer;font-size:13px}.ps-share-radio.is-active{background:color-mix(in srgb,var(--ps-highlight-background) 22%,transparent)}.ps-share-radio-glyph{width:18px;text-align:center}.ps-share-radio-hint{color:var(--ps-muted);font-size:11px;margin-left:auto}.ps-share-grant-row,.ps-share-add-row,.ps-share-link-row{display:flex;align-items:center;gap:8px;padding:5px 0}.ps-share-link-row .ps-share-section-sub{margin:0}.ps-share-grant-name{flex:1;font-size:13px}.ps-share-grant-access{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--ps-muted)}.ps-share-empty{color:var(--ps-muted);font-size:12px;padding:4px 0}.ps-share-add-input{flex:1;padding:6px 8px;border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-sm);background:var(--ps-background);color:var(--ps-foreground);font-size:13px}.ps-share-add-select{padding:6px 8px;border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-sm);background:var(--ps-background);color:var(--ps-foreground);font-size:13px}.ps-share-error{margin-top:10px;color:#ef4444;font-size:12px}.ps-share-foot-hint{margin-top:14px;color:var(--ps-muted);font-size:11px}.ps-share-section-sub{color:var(--ps-muted);font-size:11px;line-height:1.4;margin:-2px 0 6px}.ps-share-add-wrap{position:relative}.ps-share-suggestions{display:flex;flex-direction:column;margin-top:4px;border:1px solid var(--ps-modal-border);border-radius:var(--ps-radius-sm);background:var(--ps-background);max-height:108px;overflow-y:auto}.ps-share-suggestion{display:flex;align-items:baseline;gap:8px;padding:7px 10px;border:none;background:transparent;color:var(--ps-foreground);cursor:pointer;text-align:left;font-size:13px}.ps-share-suggestion:hover{background:var(--ps-highlight-background);color:var(--ps-highlight-foreground)}.ps-share-suggestion-name{font-weight:600}.ps-share-suggestion-email{color:var(--ps-muted);font-size:11px}.ps-share-suggestion:hover .ps-share-suggestion-email{color:inherit}.ps-icon-button{position:relative;min-width:44px;padding:8px 12px;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;touch-action:manipulation}.ps-icon-button-glyph{display:inline-flex;align-items:center;justify-content:center;font-size:20px;line-height:1;letter-spacing:-.02em}.ps-icon-tooltip{position:fixed;max-width:220px;white-space:normal;text-align:center;background:var(--ps-foreground);color:var(--ps-background);padding:5px 9px;border-radius:var(--ps-radius-sm);font-size:11px;line-height:1.35;font-weight:500;z-index:100000;pointer-events:none;box-shadow:0 6px 18px #0006}.ps-icon-tooltip.is-below{transform:translate(-50%)}.ps-icon-tooltip.is-above{transform:translate(-50%,-100%)}.ps-share-glyph{width:18px;height:18px;display:block}