@jini-ai/chat 0.6.3 → 0.6.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.6.5 — 2026-10-15
2
+
3
+ - Durable recovery no longer gives up on a finished CLI turn (8c6acfea): a saved successful Claude CLI `result` line carries `modelUsage.contextWindow`, which matched the context-limit pattern case-insensitively, so a dropped stream or restart ended the answer with "The assistant could not continue this answer" instead of continuing it. The pattern now requires a separator, and attempt diagnostics skip successful CLI result lines (`type: "result"`, `is_error: false`).
4
+ - `recover()` logs one `[durable-recovery]` info line per decision (9cc967cd) with runId, trigger, probe, decision and failure class only; `logger` is an optional injectable, default console.
5
+ - On phones and touch screens the attachment preview's Close and previous/next get 44px tap areas (3b6a5480); desktop is unchanged.
6
+
7
+ ## 0.6.4 — 2026-10-15
8
+
9
+ - Image preview steps through a message's or the composer's images (2444ac4d): clicking a sent image or a staged chip opens the preview; with more than one image, previous/next buttons (bottom-right) and the arrow keys step through them; Esc or a backdrop click closes it and focus returns to the thumbnail. Staged chips show a thumbnail, labelled "Image" when the name is only a content hash.
10
+ - The staged attachment tray shows two chips per row (eef66b2e): chips cap at half the tray (max 290px), so four show before the 88px tray scrolls. Chips in the pinned-context zone keep their full-width cap.
11
+ - On phones and touch screens the staged chip's remove button gets a 44px tap area (660cb9fd); chip and button sizes are unchanged.
12
+
1
13
  ## 0.6.3 — 2026-10-14
2
14
 
3
15
  - A failed MCP bridge ends the run once with its own message (e350056c): durable recovery no longer retries `MCP_BRIDGE_UNAVAILABLE` runs until the budget runs out and then shows the generic "could not continue" notice; the run is finalized with the bridge failure's exact text.
@@ -0,0 +1,24 @@
1
+ import type { ChatAttachment } from '../../core/index.js';
2
+ /** What `AttachmentPreviewModal` needs to draw previous/next; absent when there is nothing to step to. */
3
+ export interface AttachmentPreviewNavigation {
4
+ index: number;
5
+ count: number;
6
+ onPrevious: () => void;
7
+ onNext: () => void;
8
+ }
9
+ export interface AttachmentGalleryController {
10
+ /** The attachment the modal shows, or `null` while it is closed. */
11
+ opened: ChatAttachment | null;
12
+ navigation: AttachmentPreviewNavigation | undefined;
13
+ open: (attachment: ChatAttachment) => void;
14
+ close: () => void;
15
+ }
16
+ /**
17
+ * Open/close plus previous/next over `attachments`. The stepping set is snapshotted when the modal
18
+ * opens: the modal covers the chips, so the list cannot change under it in a way the operator chose.
19
+ * @complexity O(n) on open; O(1) per step.
20
+ */
21
+ export declare function useAttachmentGallery({ attachments }: {
22
+ attachments: readonly ChatAttachment[];
23
+ }, _options?: Record<string, never>): AttachmentGalleryController;
24
+ //# sourceMappingURL=AttachmentGallery.hooks.d.ts.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file Which attachment a preview modal is open on, and stepping through its images. Shared by a
3
+ * sent message's attachments (`MessageRow.tsx`) and the composer's staged chips
4
+ * (`AttachmentTray.tsx`); the rules live in `AttachmentGallery.rules.ts`.
5
+ */
6
+ import { useState } from 'react';
7
+ import { attachmentGalleryFor, stepGalleryIndex } from './AttachmentGallery.rules.js';
8
+ /**
9
+ * Open/close plus previous/next over `attachments`. The stepping set is snapshotted when the modal
10
+ * opens: the modal covers the chips, so the list cannot change under it in a way the operator chose.
11
+ * @complexity O(n) on open; O(1) per step.
12
+ */
13
+ export function useAttachmentGallery({ attachments }, _options = {}) {
14
+ const [gallery, setGallery] = useState(null);
15
+ const step = (by) => setGallery((current) => current === null ? null : { ...current, index: stepGalleryIndex({ index: current.index, count: current.items.length, step: by }) });
16
+ const count = gallery?.items.length ?? 0;
17
+ return {
18
+ opened: gallery?.items[gallery.index] ?? null,
19
+ navigation: gallery !== null && count > 1
20
+ ? { index: gallery.index, count, onPrevious: () => step(-1), onNext: () => step(1) }
21
+ : undefined,
22
+ open: (opened) => setGallery(attachmentGalleryFor({ attachments, opened })),
23
+ close: () => setGallery(null),
24
+ };
25
+ }
26
+ //# sourceMappingURL=AttachmentGallery.hooks.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @file Pure rules behind the attachment preview's image stepping (`AttachmentGallery.hooks.ts`):
3
+ * which attachments a preview can step through, the wrap-around index math, and whether a filename
4
+ * is worth showing at all. (The arrow-key mapping sits with the modal's own key handler in
5
+ * `AttachmentPreviewModal.hooks.ts`, which this file already imports from.)
6
+ *
7
+ * **Images only.** A message (or the composer's staged set) can mix screenshots with a CSV or a
8
+ * PDF; previous/next walks the images, the same rule (`looksLikeImageAttachment`) the thumbnails
9
+ * and the modal's own `<img>` branch use. A non-image opens on its own, with no stepping - its
10
+ * preview is a text pane or a metadata card, not something to flip through. `ChatAttachment.kind`
11
+ * has no video variant, so there is no video case to step through either.
12
+ */
13
+ import type { ChatAttachment } from '../../core/index.js';
14
+ export interface AttachmentGallery {
15
+ items: readonly ChatAttachment[];
16
+ index: number;
17
+ }
18
+ /**
19
+ * The set a preview opened on `opened` can step through: every image in `attachments`, in order,
20
+ * positioned at `opened`; or just `opened` alone when it is not an image (or not in the list).
21
+ * @complexity O(n) in `attachments.length`.
22
+ */
23
+ export declare function attachmentGalleryFor({ attachments, opened }: {
24
+ attachments: readonly ChatAttachment[];
25
+ opened: ChatAttachment;
26
+ }, _options?: Record<string, never>): AttachmentGallery;
27
+ /**
28
+ * `index` moved by `step`, wrapping past either end so previous-on-first lands on the last image.
29
+ * @complexity O(1).
30
+ */
31
+ export declare function stepGalleryIndex({ index, count, step }: {
32
+ index: number;
33
+ count: number;
34
+ step: number;
35
+ }): number;
36
+ /**
37
+ * True for a name that is only a content hash (e.g. `b5aa983b7af337a54a4e23f8e0124c9d.png`, what a
38
+ * media-library blob carries), which tells the operator nothing - the chip shows the thumbnail and
39
+ * a plain "Image" label instead.
40
+ */
41
+ export declare function isOpaqueAttachmentName({ name }: {
42
+ name: string;
43
+ }): boolean;
44
+ //# sourceMappingURL=AttachmentGallery.rules.d.ts.map
@@ -0,0 +1,31 @@
1
+ import { looksLikeImageAttachment } from './AttachmentPreviewModal.hooks.js';
2
+ /**
3
+ * The set a preview opened on `opened` can step through: every image in `attachments`, in order,
4
+ * positioned at `opened`; or just `opened` alone when it is not an image (or not in the list).
5
+ * @complexity O(n) in `attachments.length`.
6
+ */
7
+ export function attachmentGalleryFor({ attachments, opened }, _options = {}) {
8
+ if (!looksLikeImageAttachment({ attachment: opened }))
9
+ return { items: [opened], index: 0 };
10
+ const items = attachments.filter((attachment) => looksLikeImageAttachment({ attachment }));
11
+ const index = items.findIndex((attachment) => attachment.path === opened.path);
12
+ return index === -1 ? { items: [opened], index: 0 } : { items, index };
13
+ }
14
+ /**
15
+ * `index` moved by `step`, wrapping past either end so previous-on-first lands on the last image.
16
+ * @complexity O(1).
17
+ */
18
+ export function stepGalleryIndex({ index, count, step }) {
19
+ if (count <= 0)
20
+ return 0;
21
+ return (((index + step) % count) + count) % count;
22
+ }
23
+ /**
24
+ * True for a name that is only a content hash (e.g. `b5aa983b7af337a54a4e23f8e0124c9d.png`, what a
25
+ * media-library blob carries), which tells the operator nothing - the chip shows the thumbnail and
26
+ * a plain "Image" label instead.
27
+ */
28
+ export function isOpaqueAttachmentName({ name }) {
29
+ return /^[0-9a-f]{20,}(\.[a-z0-9]{1,5})?$/iu.test(name);
30
+ }
31
+ //# sourceMappingURL=AttachmentGallery.rules.js.map
@@ -1,7 +1,10 @@
1
1
  import type { ChatAttachment } from '../../core/index.js';
2
+ import type { AttachmentPreviewNavigation } from './AttachmentGallery.hooks.js';
2
3
  export interface AttachmentPreviewModalProps {
3
4
  attachment: ChatAttachment;
4
5
  onClose: () => void;
6
+ /** Previous/next over the surrounding images; omitted for a lone attachment. */
7
+ navigation?: AttachmentPreviewNavigation | undefined;
5
8
  }
6
- export declare function AttachmentPreviewModal({ attachment, onClose }: AttachmentPreviewModalProps): import("react").JSX.Element;
9
+ export declare function AttachmentPreviewModal({ attachment, onClose, navigation }: AttachmentPreviewModalProps): import("react").JSX.Element;
7
10
  //# sourceMappingURL=AttachmentPreviewModal.d.ts.map
@@ -36,8 +36,9 @@
36
36
  * fallback that `onError` can retract - never a second, competing source of truth for a format the
37
37
  * server already classified.
38
38
  */
39
- import { type MouseEvent, type RefCallback, type SyntheticEvent } from 'react';
39
+ import { type KeyboardEvent, type MouseEvent, type RefCallback, type SyntheticEvent } from 'react';
40
40
  import type { ChatAttachment } from '../../core/index.js';
41
+ import type { AttachmentPreviewNavigation } from './AttachmentGallery.hooks.js';
41
42
  export type AttachmentPreviewStatus = 'image' | 'text' | 'unsupported';
42
43
  /** Hard cap on rendered text-preview characters, so a large attached log/CSV cannot hang the tab painting one giant `<pre>`. */
43
44
  export declare const MAX_TEXT_PREVIEW_CHARS = 200000;
@@ -45,6 +46,10 @@ export declare const MAX_TEXT_PREVIEW_CHARS = 200000;
45
46
  export declare function looksLikeImageAttachment({ attachment }: {
46
47
  attachment: Pick<ChatAttachment, 'kind' | 'name'>;
47
48
  }): boolean;
49
+ /** The image step an arrow key asks for in the preview (`-1`/`1`), or `0` for any other key. */
50
+ export declare function galleryStepForKey({ key }: {
51
+ key: string;
52
+ }): number;
48
53
  /**
49
54
  * Reads `file` as text via `FileReader`, not the newer `Blob.prototype.text()` promise API: both
50
55
  * are supported in every real evergreen browser, but jsdom (this package's test environment,
@@ -79,6 +84,8 @@ export interface AttachmentPreviewController {
79
84
  sizeLabel: string | null;
80
85
  handleNativeCancel: (event: SyntheticEvent<HTMLDialogElement>) => void;
81
86
  handleBackdropClick: (event: MouseEvent<HTMLDialogElement>) => void;
87
+ /** ←/→ step through `navigation`'s images; a no-op without it. */
88
+ handleKeyDown: (event: KeyboardEvent<HTMLDialogElement>) => void;
82
89
  }
83
90
  /**
84
91
  * Drives one `AttachmentPreviewModal` instance for `attachment`. The caller conditionally mounts
@@ -88,9 +95,10 @@ export interface AttachmentPreviewController {
88
95
  * read on mount (bounded in practice by the upload route's own 20 MB per-attachment cap, and by
89
96
  * {@link MAX_TEXT_PREVIEW_CHARS} for what actually renders).
90
97
  */
91
- export declare function useAttachmentPreviewModal({ attachment, onClose }: {
98
+ export declare function useAttachmentPreviewModal({ attachment, onClose, navigation }: {
92
99
  attachment: ChatAttachment;
93
100
  onClose: () => void;
101
+ navigation?: AttachmentPreviewNavigation | undefined;
94
102
  }, { readText }?: {
95
103
  readText?: (required: {
96
104
  file: File;
@@ -38,7 +38,7 @@
38
38
  */
39
39
  import { useEffect, useState } from 'react';
40
40
  import { getAttachmentPreviewSource } from '../hooks/attachment-preview-cache.js';
41
- import { formatAttachmentSize } from './AttachmentTray.js';
41
+ import { formatAttachmentSize } from './attachment-size.js';
42
42
  /** Extensions attempted as an `<img>` even when `attachment.kind` says otherwise — see module doc. */
43
43
  const IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg']);
44
44
  /** Extensions shown in a scrollable monospace pane. Deliberately narrow — see task scope: "txt, md, json, csv, and similar", not every source-code language. */
@@ -53,6 +53,14 @@ function extensionOf(name) {
53
53
  export function looksLikeImageAttachment({ attachment }) {
54
54
  return attachment.kind === 'image' || IMAGE_PREVIEW_EXTENSIONS.has(extensionOf(attachment.name));
55
55
  }
56
+ /** The image step an arrow key asks for in the preview (`-1`/`1`), or `0` for any other key. */
57
+ export function galleryStepForKey({ key }) {
58
+ if (key === 'ArrowLeft')
59
+ return -1;
60
+ if (key === 'ArrowRight')
61
+ return 1;
62
+ return 0;
63
+ }
56
64
  function looksLikeTextAttachment(name) {
57
65
  return TEXT_PREVIEW_EXTENSIONS.has(extensionOf(name));
58
66
  }
@@ -130,13 +138,16 @@ function createDialogRef() {
130
138
  * read on mount (bounded in practice by the upload route's own 20 MB per-attachment cap, and by
131
139
  * {@link MAX_TEXT_PREVIEW_CHARS} for what actually renders).
132
140
  */
133
- export function useAttachmentPreviewModal({ attachment, onClose }, { readText = readFileAsText } = {}) {
141
+ export function useAttachmentPreviewModal({ attachment, onClose, navigation }, { readText = readFileAsText } = {}) {
134
142
  // Lazy state, not `useCallback`: a new ref identity would make React detach and re-attach it,
135
143
  // closing and reopening the dialog. The caller mounts a fresh instance per opened attachment (never
136
144
  // reuses one instance across two different open attachments), so there is no "attachment changed
137
- // while still open" transition for the ref to react to.
145
+ // while still open" transition for the ref to react to. Previous/next is the one exception: it swaps
146
+ // `attachment` under the same open dialog, keeping focus on the button pressed, so the failure flag
147
+ // below is keyed by path rather than a boolean that would carry over to the next image.
138
148
  const [dialogRef] = useState(createDialogRef);
139
- const [imageFailed, setImageFailed] = useState(false);
149
+ const [failedImagePath, setFailedImagePath] = useState(null);
150
+ const imageFailed = failedImagePath === attachment.path;
140
151
  const [imageUrl, setImageUrl] = useState(undefined);
141
152
  const [text, setText] = useState(undefined);
142
153
  const [textLoading, setTextLoading] = useState(false);
@@ -200,16 +211,27 @@ export function useAttachmentPreviewModal({ attachment, onClose }, { readText =
200
211
  if (event.target === event.currentTarget)
201
212
  onClose();
202
213
  }
214
+ function handleKeyDown(event) {
215
+ const step = navigation ? galleryStepForKey({ key: event.key }) : 0;
216
+ if (step === 0 || !navigation)
217
+ return;
218
+ event.preventDefault();
219
+ if (step < 0)
220
+ navigation.onPrevious();
221
+ else
222
+ navigation.onNext();
223
+ }
203
224
  return {
204
225
  dialogRef,
205
226
  status,
206
227
  imageUrl,
207
- onImageError: () => setImageFailed(true),
228
+ onImageError: () => setFailedImagePath(attachment.path),
208
229
  textLoading,
209
230
  text,
210
231
  sizeLabel: formatAttachmentSize({ size: attachment.size }),
211
232
  handleNativeCancel,
212
233
  handleBackdropClick,
234
+ handleKeyDown,
213
235
  };
214
236
  }
215
237
  //# sourceMappingURL=AttachmentPreviewModal.hooks.js.map
@@ -6,13 +6,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  * a scrollable monospace pane for a text-ish file, or - honestly, never a faked preview - the
7
7
  * filename/type/size for anything else. All state/effects live in `useAttachmentPreviewModal`; this
8
8
  * is the presentational shell, native-`<dialog>`-based like `Markdown.tsx`'s `TableBlock` pop-out.
9
+ * With `navigation` (more than one image in the message or the composer's staged set) a footer adds
10
+ * previous/next side by side in the bottom-right corner, plus ←/→ on the dialog.
9
11
  */
10
12
  import { useT } from '../hooks/context.js';
11
13
  import { Icon } from './Icon.js';
12
14
  import { looksLikeImageAttachment, useAttachmentPreviewModal } from './AttachmentPreviewModal.hooks.js';
13
- export function AttachmentPreviewModal({ attachment, onClose }) {
15
+ export function AttachmentPreviewModal({ attachment, onClose, navigation }) {
14
16
  const t = useT();
15
- const c = useAttachmentPreviewModal({ attachment: attachment, onClose: onClose });
16
- return (_jsxs("dialog", { ref: c.dialogRef, className: "jini-attachment-preview-dialog", role: "dialog", "aria-modal": "true", "aria-label": t('{name} preview', { name: attachment.name }), onCancel: c.handleNativeCancel, onClick: c.handleBackdropClick, children: [_jsxs("div", { className: "jini-attachment-preview-header", children: [_jsx("span", { className: "jini-attachment-preview-name", title: attachment.name, children: attachment.name }), _jsx("button", { type: "button", className: "jini-attachment-preview-close", onClick: onClose, title: t('Close'), "aria-label": t('Close'), children: _jsx(Icon, { name: "close", size: 16 }) })] }), _jsx("div", { className: "jini-attachment-preview-body", children: c.status === 'image' && c.imageUrl ? (_jsx("img", { src: c.imageUrl, alt: attachment.name, className: "jini-attachment-preview-image", onError: c.onImageError })) : c.status === 'text' ? (c.textLoading ? (_jsx("div", { className: "jini-attachment-preview-loading", children: t('Loading…') })) : (_jsxs(_Fragment, { children: [_jsx("pre", { className: "jini-attachment-preview-text", children: c.text?.content ?? '' }), c.text?.truncated ? (_jsx("div", { className: "jini-attachment-preview-truncated", children: t('Preview truncated — this file is larger than the preview limit.') })) : null] }))) : (_jsxs("div", { className: "jini-attachment-preview-meta", children: [_jsxs("div", { className: "jini-attachment-preview-meta-row", children: [_jsx("span", { className: "jini-attachment-preview-meta-label", children: t('Name') }), _jsx("span", { children: attachment.name })] }), _jsxs("div", { className: "jini-attachment-preview-meta-row", children: [_jsx("span", { className: "jini-attachment-preview-meta-label", children: t('Type') }), _jsx("span", { children: looksLikeImageAttachment({ attachment: attachment }) ? t('Image') : t('File') })] }), c.sizeLabel ? (_jsxs("div", { className: "jini-attachment-preview-meta-row", children: [_jsx("span", { className: "jini-attachment-preview-meta-label", children: t('Size') }), _jsx("span", { children: c.sizeLabel })] })) : null, _jsx("div", { className: "jini-attachment-preview-meta-note", children: t('A preview is not available for this file.') })] })) })] }));
17
+ const c = useAttachmentPreviewModal({ attachment: attachment, onClose: onClose, navigation });
18
+ return (_jsxs("dialog", { ref: c.dialogRef, className: `jini-attachment-preview-dialog${navigation ? ' has-nav' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": t('{name} preview', { name: attachment.name }), onCancel: c.handleNativeCancel, onClick: c.handleBackdropClick, onKeyDown: c.handleKeyDown, children: [_jsxs("div", { className: "jini-attachment-preview-header", children: [_jsx("span", { className: "jini-attachment-preview-name", title: attachment.name, children: attachment.name }), _jsx("button", { type: "button", className: "jini-attachment-preview-close", onClick: onClose, title: t('Close'), "aria-label": t('Close'), children: _jsx(Icon, { name: "close", size: 16 }) })] }), _jsx("div", { className: "jini-attachment-preview-body", children: c.status === 'image' && c.imageUrl ? (_jsx("img", { src: c.imageUrl, alt: attachment.name, className: "jini-attachment-preview-image", onError: c.onImageError })) : c.status === 'text' ? (c.textLoading ? (_jsx("div", { className: "jini-attachment-preview-loading", children: t('Loading…') })) : (_jsxs(_Fragment, { children: [_jsx("pre", { className: "jini-attachment-preview-text", children: c.text?.content ?? '' }), c.text?.truncated ? (_jsx("div", { className: "jini-attachment-preview-truncated", children: t('Preview truncated — this file is larger than the preview limit.') })) : null] }))) : (_jsxs("div", { className: "jini-attachment-preview-meta", children: [_jsxs("div", { className: "jini-attachment-preview-meta-row", children: [_jsx("span", { className: "jini-attachment-preview-meta-label", children: t('Name') }), _jsx("span", { children: attachment.name })] }), _jsxs("div", { className: "jini-attachment-preview-meta-row", children: [_jsx("span", { className: "jini-attachment-preview-meta-label", children: t('Type') }), _jsx("span", { children: looksLikeImageAttachment({ attachment: attachment }) ? t('Image') : t('File') })] }), c.sizeLabel ? (_jsxs("div", { className: "jini-attachment-preview-meta-row", children: [_jsx("span", { className: "jini-attachment-preview-meta-label", children: t('Size') }), _jsx("span", { children: c.sizeLabel })] })) : null, _jsx("div", { className: "jini-attachment-preview-meta-note", children: t('A preview is not available for this file.') })] })) }), navigation ? (_jsxs("div", { className: "jini-attachment-preview-nav", children: [_jsx("span", { className: "jini-attachment-preview-count", "aria-live": "polite", children: t('{index} / {count}', { index: navigation.index + 1, count: navigation.count }) }), _jsx("button", { type: "button", className: "jini-attachment-preview-step", onClick: navigation.onPrevious, title: t('Previous image'), "aria-label": t('Previous image'), children: _jsx(Icon, { name: "chevron-left", size: 18 }) }), _jsx("button", { type: "button", className: "jini-attachment-preview-step", onClick: navigation.onNext, title: t('Next image'), "aria-label": t('Next image'), children: _jsx(Icon, { name: "chevron-right", size: 18 }) })] })) : null] }));
17
19
  }
18
20
  //# sourceMappingURL=AttachmentPreviewModal.js.map
@@ -1,8 +1,10 @@
1
1
  import type { AttachmentTraySlot } from '../slots.js';
2
- export type AttachmentTrayProps = AttachmentTraySlot;
3
- export declare function AttachmentTray({ attachments, onRemove, renderItem }: AttachmentTrayProps): import("react").JSX.Element | null;
4
- /** Formats optional byte counts for the compact composer attachment chip. */
5
- export declare function formatAttachmentSize({ size }: {
6
- size: number | undefined;
7
- }): string | null;
2
+ import { type ObjectUrlPort } from './SentAttachment.hooks.js';
3
+ import { formatAttachmentSize } from './attachment-size.js';
4
+ export { formatAttachmentSize };
5
+ export type AttachmentTrayProps = AttachmentTraySlot & {
6
+ /** Blob-URL port for chip thumbnails; defaults to the platform's (see `SentAttachment.hooks.ts`). */
7
+ objectUrls?: ObjectUrlPort | undefined;
8
+ };
9
+ export declare function AttachmentTray({ attachments, onRemove, renderItem, objectUrls }: AttachmentTrayProps): import("react").JSX.Element | null;
8
10
  //# sourceMappingURL=AttachmentTray.d.ts.map
@@ -1,30 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useT } from '../hooks/context.js';
3
3
  import { Icon } from './Icon.js';
4
- export function AttachmentTray({ attachments, onRemove, renderItem }) {
4
+ import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
5
+ import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
6
+ import { isOpaqueAttachmentName } from './AttachmentGallery.rules.js';
7
+ import { useSentAttachmentThumbnail } from './SentAttachment.hooks.js';
8
+ import { formatAttachmentSize } from './attachment-size.js';
9
+ // Re-exported: callers and tests have always imported it from here.
10
+ export { formatAttachmentSize };
11
+ export function AttachmentTray({ attachments, onRemove, renderItem, objectUrls }) {
5
12
  const t = useT();
13
+ const gallery = useAttachmentGallery({ attachments });
6
14
  if (attachments.length === 0)
7
15
  return null;
8
- return (_jsx("div", { className: "jini-attachment-tray", "data-testid": "composer-attachment-tray", children: attachments.map((a) => (_jsxs("div", { className: "jini-attachment-chip", "data-testid": "attachment-chip", "data-attachment-kind": a.kind, "data-attachment-name": a.name, children: [renderItem ? renderItem(a) : _jsx(DefaultAttachmentChip, { attachment: a }), _jsx("button", { type: "button", className: "jini-attachment-remove", onClick: () => onRemove(a.path), title: t('Remove {name}', { name: a.name }), "aria-label": t('Remove {name}', { name: a.name }), children: _jsx(Icon, { name: "close", size: 12 }) })] }, a.path))) }));
16
+ return (_jsxs("div", { className: "jini-attachment-tray", "data-testid": "composer-attachment-tray", children: [attachments.map((a) => (_jsxs("div", { className: "jini-attachment-chip", "data-testid": "attachment-chip", "data-attachment-kind": a.kind, "data-attachment-name": a.name, children: [renderItem ? renderItem(a) : _jsx(DefaultAttachmentChip, { attachment: a, onOpen: gallery.open, objectUrls: objectUrls }), _jsx("button", { type: "button", className: "jini-attachment-remove", onClick: () => onRemove(a.path), title: t('Remove {name}', { name: a.name }), "aria-label": t('Remove {name}', { name: a.name }), children: _jsx(Icon, { name: "close", size: 12 }) })] }, a.path))), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
9
17
  }
10
- function DefaultAttachmentChip({ attachment }) {
18
+ function DefaultAttachmentChip({ attachment, onOpen, objectUrls }) {
19
+ const t = useT();
20
+ const { thumbnailUrl, onThumbnailError } = useSentAttachmentThumbnail({ attachment }, objectUrls === undefined ? {} : { objectUrls });
11
21
  const size = formatAttachmentSize({ size: attachment.size });
12
- return (_jsxs("span", { className: "jini-attachment-chip-body", children: [_jsx("span", { className: `jini-attachment-chip-icon is-${attachment.kind}`, children: _jsx(Icon, { name: attachment.kind === 'image' ? 'image' : 'file', size: 15 }) }), _jsxs("span", { className: "jini-attachment-chip-copy", children: [_jsx("span", { className: "jini-attachment-chip-name", title: attachment.name, children: attachment.name }), size ? _jsx("small", { className: "jini-attachment-chip-size", children: size }) : null] })] }));
13
- }
14
- /** Formats optional byte counts for the compact composer attachment chip. */
15
- export function formatAttachmentSize({ size }) {
16
- if (size === undefined || !Number.isFinite(size) || size < 0)
17
- return null;
18
- if (size < 1_024)
19
- return `${Math.round(size)} B`;
20
- const units = ['KB', 'MB', 'GB'];
21
- let value = size / 1_024;
22
- let unitIndex = 0;
23
- while (value >= 1_024 && unitIndex < units.length - 1) {
24
- value /= 1_024;
25
- unitIndex += 1;
26
- }
27
- const formatted = value < 10 ? value.toFixed(1).replace(/\.0$/u, '') : Math.round(value).toString();
28
- return `${formatted} ${units[unitIndex]}`;
22
+ const label = thumbnailUrl && isOpaqueAttachmentName({ name: attachment.name }) ? t('Image') : attachment.name;
23
+ return (_jsxs("button", { type: "button", className: "jini-attachment-chip-body", onClick: () => onOpen(attachment), title: t('Open {name}', { name: attachment.name }), "aria-label": t('Open {name}', { name: attachment.name }), children: [thumbnailUrl ? (_jsx("img", { className: "jini-attachment-chip-thumb", src: thumbnailUrl, alt: "", onError: onThumbnailError })) : (_jsx("span", { className: `jini-attachment-chip-icon is-${attachment.kind}`, children: _jsx(Icon, { name: attachment.kind === 'image' ? 'image' : 'file', size: 15 }) })), _jsxs("span", { className: "jini-attachment-chip-copy", children: [_jsx("span", { className: "jini-attachment-chip-name", children: label }), size ? _jsx("small", { className: "jini-attachment-chip-size", children: size }) : null] })] }));
29
24
  }
30
25
  //# sourceMappingURL=AttachmentTray.js.map
@@ -7,7 +7,7 @@
7
7
  * AgentIcon and WorkingDirPicker.
8
8
  */
9
9
  import type { SVGProps } from 'react';
10
- export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-right' | 'attach' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'copy';
10
+ export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'attach' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'copy';
11
11
  interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
12
12
  name: IconName;
13
13
  size?: number | string;
@@ -30,6 +30,8 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }) {
30
30
  return (_jsxs("svg", { ...common, children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("path", { d: "m20 20-3.5-3.5" })] }));
31
31
  case 'plus':
32
32
  return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M12 5v14" }), _jsx("path", { d: "M5 12h14" })] }));
33
+ case 'chevron-left':
34
+ return (_jsx("svg", { ...common, children: _jsx("path", { d: "m15 18-6-6 6-6" }) }));
33
35
  case 'chevron-right':
34
36
  return (_jsx("svg", { ...common, children: _jsx("path", { d: "m9 18 6-6-6-6" }) }));
35
37
  case 'attach':
@@ -51,7 +51,7 @@ import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary }
51
51
  * not appear until the whole message is done, not merely until its current
52
52
  * chunk stopped arriving.
53
53
  */
54
- import React, { memo, useState } from 'react';
54
+ import React, { memo } from 'react';
55
55
  import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
56
56
  import { useToolTimeline } from '../hooks/useToolTimeline.js';
57
57
  import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
@@ -63,6 +63,7 @@ import { useT, useExtEventRendererRegistry } from '../hooks/context.js';
63
63
  import { ExtEventErrorBoundary } from './ExtEventErrorBoundary.js';
64
64
  import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
65
65
  import { SentAttachmentButton } from './SentAttachment.js';
66
+ import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
66
67
  import { Icon } from './Icon.js';
67
68
  import { Markdown } from './Markdown.js';
68
69
  import { ToolCard } from './ToolCard.js';
@@ -156,6 +157,8 @@ function CopyMessageButton({ text, label }) {
156
157
  void copy(text);
157
158
  }, children: _jsx(Icon, { name: copied ? 'check' : 'copy' }) }), _jsx("span", { className: "jini-message-copy-status", "aria-live": "polite", children: copied ? t('Copied to clipboard') : '' })] }));
158
159
  }
160
+ /** Stable empty list for a message with no attachments. */
161
+ const NO_ATTACHMENTS = [];
159
162
  /**
160
163
  * Memoized so a transcript re-render (a streaming update to the newest message, or a parent that
161
164
  * re-renders per composer keystroke) only re-renders rows whose props actually changed. Messages
@@ -171,9 +174,10 @@ export const MessageRow = memo(function MessageRow({ message, usageSummary, show
171
174
  // Which attachment chip (if any) this row's preview modal is open for. Row-local, not
172
175
  // module-level: two attachments in the same message can never be "open" at once, but two
173
176
  // different messages opening their own attachments independently is fine and expected.
174
- const [openAttachment, setOpenAttachment] = useState(null);
177
+ // Previous/next steps through this message's images only.
178
+ const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
175
179
  if (message.role === 'user') {
176
- return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: setOpenAttachment, renderAttachment: renderAttachment }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), openAttachment ? (_jsx(AttachmentPreviewModal, { attachment: openAttachment, onClose: () => setOpenAttachment(null) })) : null] }));
180
+ return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
177
181
  }
178
182
  const terminalNotice = terminalMessageNotice({ message }, {});
179
183
  const visibleContent = stripArtifact({ content: message.content });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @file Byte-count label shared by the composer's attachment chip (`AttachmentTray.tsx`) and the
3
+ * preview modal's metadata view. Its own file so the preview modal does not import the tray, which
4
+ * itself renders the preview modal.
5
+ */
6
+ /** Formats optional byte counts for the compact composer attachment chip. */
7
+ export declare function formatAttachmentSize({ size }: {
8
+ size: number | undefined;
9
+ }): string | null;
10
+ //# sourceMappingURL=attachment-size.d.ts.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file Byte-count label shared by the composer's attachment chip (`AttachmentTray.tsx`) and the
3
+ * preview modal's metadata view. Its own file so the preview modal does not import the tray, which
4
+ * itself renders the preview modal.
5
+ */
6
+ /** Formats optional byte counts for the compact composer attachment chip. */
7
+ export function formatAttachmentSize({ size }) {
8
+ if (size === undefined || !Number.isFinite(size) || size < 0)
9
+ return null;
10
+ if (size < 1_024)
11
+ return `${Math.round(size)} B`;
12
+ const units = ['KB', 'MB', 'GB'];
13
+ let value = size / 1_024;
14
+ let unitIndex = 0;
15
+ while (value >= 1_024 && unitIndex < units.length - 1) {
16
+ value /= 1_024;
17
+ unitIndex += 1;
18
+ }
19
+ const formatted = value < 10 ? value.toFixed(1).replace(/\.0$/u, '') : Math.round(value).toString();
20
+ return `${formatted} ${units[unitIndex]}`;
21
+ }
22
+ //# sourceMappingURL=attachment-size.js.map
@@ -1,2 +1,2 @@
1
- export declare const CHAT_PANE_STYLES = "\n.jini-chat-pane {\n --jini-chat-bg: #faf9f7;\n --jini-chat-panel: #fdfcfa;\n --jini-chat-text: #1a1916;\n --jini-chat-text-strong: #0d0c0a;\n --jini-chat-muted: #74716b;\n --jini-chat-faint: #b3b0a8;\n --jini-chat-border: #e1e5eb;\n --jini-chat-border-strong: #c9d0da;\n --jini-chat-border-soft: #edf0f4;\n --jini-chat-subtle: #f4f5f7;\n --jini-chat-accent: #c96442;\n --jini-chat-accent-soft: #fbeee5;\n --jini-chat-danger: #e5484d;\n --jini-chat-radius: 8px;\n --jini-chat-radius-lg: 12px;\n --bg-panel: var(--jini-chat-panel);\n --bg-subtle: var(--jini-chat-subtle);\n --border: var(--jini-chat-border);\n --border-strong: var(--jini-chat-border-strong);\n --text: var(--jini-chat-text);\n --text-strong: var(--jini-chat-text-strong);\n --text-muted: var(--jini-chat-muted);\n --danger: var(--jini-chat-danger);\n --radius: var(--jini-chat-radius);\n --radius-lg: var(--jini-chat-radius-lg);\n position: relative;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr) auto;\n min-width: 0;\n min-height: 0;\n height: 100%;\n /* clip after the hidden fallback: a hidden box still scrolls programmatically (a focus() or\n scrollIntoView() inside it), which shifts the pinned composer up and leaves blank pane below. */\n overflow: hidden;\n overflow: clip;\n color: var(--jini-chat-text);\n background: var(--jini-chat-bg);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Microsoft YaHei UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n font-size: 13.5px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.jini-chat-pane button,\n.jini-runtime-popover button,\n.jini-runtime-popover select {\n font: inherit;\n}\n.jini-chat-pane__header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n min-height: 74px;\n padding: 18px 22px 14px;\n border-bottom: 1px solid var(--jini-chat-border-soft);\n}\n.jini-chat-pane__heading { min-width: 0; }\n.jini-chat-pane__eyebrow {\n display: block;\n margin-bottom: 4px;\n color: var(--jini-chat-muted);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 10px;\n letter-spacing: .1em;\n text-transform: uppercase;\n}\n.jini-chat-pane__title {\n overflow: hidden;\n margin: 0;\n font-size: 21px;\n font-weight: 650;\n letter-spacing: -.025em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane__new-thread {\n padding: 8px 12px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-chat-pane__body {\n position: relative;\n min-height: 0;\n overflow: hidden;\n overflow: clip;\n}\n.jini-chat-pane .jini-message-list {\n height: 100%;\n overflow-y: auto;\n /*\n * .jini-chat-pane__controls is an absolutely-positioned overlay (not a flex/grid sibling that\n * pushes this list up), so its real height has to be reserved here instead. This used to be a\n * flat 240px guess -- and had already gone stale once before that: it started at 170px, a live\n * render outgrew it (composer + footer + working-directory row alone measured 179.75px, clipping\n * the last message's tail under the overlay), and 240px was chosen as a margin above THAT\n * measurement, not a value anything keeps in sync. useChatPaneControlsHeight now measures the\n * overlay's real height live via ResizeObserver and publishes it as\n * --jini-chat-controls-height on the pane root, so this reservation tracks the composer's\n * actual content (an attachment tray, a wrapped multi-line draft, a taller runtime-picker\n * popover) instead of a number that is only ever correct until the next thing that grows it.\n * 240px survives as the fallback for the rare environment without ResizeObserver (SSR; a test\n * harness that never polyfills it) -- everywhere else, the calc() wins.\n */\n padding: 20px 22px calc(var(--jini-chat-controls-height, 240px) + 24px);\n scrollbar-width: thin;\n}\n.jini-chat-pane .jini-message {\n margin-bottom: 20px;\n color: var(--jini-chat-text);\n font-size: 14px;\n line-height: 1.62;\n}\n/*\n * Off-screen rows skip style, layout and paint. Owner report 2026-10-11: typing in a long chat\n * lagged. React was not the cost (no row re-renders per key); the browser was. A keystroke's DOM\n * change can invalidate style for the whole document (a host dock's :has() rule did),\n * and a transcript with ~2,000 tool cards and 3.5 MB of tool output then cost ~50 ms of style\n * recalc per key. With this, only the rows on screen are recalculated (~8 ms). The DOM stays\n * whole: find-in-page, textContent readers and screen readers still see every row.\n * 'auto' in contain-intrinsic-size keeps a row's last real height once it has rendered; the\n * auto-scroll ResizeObserver re-sticks to the bottom as estimated rows resolve.\n * Containment clips paint at the row's edge, so overflow-clip-margin leaves room for focus rings\n * (2px outline + 2px offset); @supports keeps browsers without it on the old, unclipped path.\n */\n@supports (overflow-clip-margin: 8px) {\n .jini-chat-pane .jini-message {\n content-visibility: auto;\n contain-intrinsic-size: auto 160px;\n overflow-clip-margin: 8px;\n }\n}\n/* A mid-run user bubble is nested inside an assistant turn, so the preceding tool/text block\n * has no message-row bottom margin. Give it the same separation as an ordinary next turn. */\n.jini-chat-pane .jini-message-user--mid-run {\n margin-top: 20px;\n}\n/*\n * Bubble on the content, not the row: MessageRow.tsx renders attachments as siblings of\n * .jini-message-content inside .jini-message-user, each with their own chip styling -- bubbling\n * the whole row would nest one background box inside another. This replaces a dead rule this file\n * shipped with (.jini-message-row[data-role=\"user\"] / .jini-message-row.user): MessageRow.tsx\n * has never rendered a jini-message-row class or a data-role attribute, and a user message's\n * root class is jini-message-user, not bare \"user\" -- the selector never matched anything real.\n */\n.jini-chat-pane .jini-message-user .jini-message-content {\n margin-left: auto;\n padding: 10px 13px;\n width: fit-content;\n max-width: 88%;\n /* User bubbles share this style at every nesting depth, regardless of message length. */\n background: var(--jini-chat-user-bubble, #1a1b1c);\n color: var(--jini-chat-user-bubble-ink, #fff);\n border-radius: 12px 12px 3px 12px;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachments {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n justify-content: flex-end;\n margin-bottom: 4px;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip {\n font: inherit;\n font-size: 11px;\n line-height: 1.4;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n padding: 3px 10px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip:hover {\n color: var(--jini-chat-text);\n border-color: var(--jini-chat-border-strong);\n}\n/* A sent image this browser still holds (SentAttachment.tsx): a small square thumbnail instead of\n * the filename pill. Same click target and preview modal; cover-cropped so mixed aspect ratios line up. */\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip.is-thumbnail {\n width: 64px;\n height: 64px;\n padding: 0;\n overflow: hidden;\n border-radius: 10px;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip.is-thumbnail:focus-visible {\n outline: 2px solid var(--jini-chat-accent);\n outline-offset: 2px;\n}\n.jini-chat-pane .jini-message-attachment-thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n.jini-chat-pane .jini-message-agent {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: .02em;\n text-transform: uppercase;\n color: var(--jini-chat-faint);\n margin-bottom: 4px;\n}\n.jini-chat-pane .jini-message-error { color: var(--danger); font-size: 13px; }\n.jini-chat-pane .jini-message-pending { color: var(--jini-chat-muted); font-size: 13px; font-style: italic; }\n/*\n * The pending-turn strip: a composer-driven turn typed while a run is already streaming, rendered\n * as the newest entry in the transcript (MessageList.tsx's pendingPrompt prop) rather than a\n * banner bolted above the composer \u2014 matching Claude Code/ChatGPT's own pending-message UX\n * (previously ChatPane.tsx's ChatPaneQueuedPrompt, which rendered between the drop-target's drag\n * announcement and the composer; that component is gone).\n *\n * Owner's own framing was \"why isn't the queue message just put in the chat\" \u2014 so\n * .jini-chat-pane__queued-text below is deliberately NOT a variant banner shape; it copies\n * .jini-message-user .jini-message-content's bubble geometry line for line (width: fit-content,\n * max-width: 88%, same padding/border-radius) so it reads as the operator's own message, not a\n * system notice. What marks it as NOT YET SENT is two properties a real sent bubble never carries:\n * a dashed border (a sent bubble has none at all) and reduced opacity \u2014 chosen over the old boxed\n * strip-plus-label treatment because those two are legible at a glance without adding a second\n * visual language next to the transcript's own. The former \"QUEUED\" chip label is gone for the same\n * reason: once the bubble itself reads as pending, a label restates it. role=\"status\" plus the\n * aria-label on the row (MessageList.tsx) still carry that meaning to assistive tech that can't see\n * the dashed border. The cancel action is a separate, smaller line below the bubble (mirroring\n * .jini-message-actions--user's own placement under a real message) rather than inline inside it,\n * so it reads as secondary to the bubble, not competing with it.\n *\n * Reuses this theme's own tokens rather than the currentColor/color-mix approach reference.css uses\n * for the same rules \u2014 that file is deliberately token-free because it ships unstyled and undocked\n * from any root that defines custom properties, but this stylesheet's .jini-chat-pane root already\n * defines concrete --jini-chat-* values for every other rule here, so matching that (not\n * reference.css's host-agnostic fallback) is the actual local convention.\n * NOTE: this comment avoids backtick quoting on purpose, same as the cancel-button comment below \u2014\n * the whole stylesheet lives inside a JS template literal, so a literal backtick here would\n * terminate the string, not just this comment.\n */\n.jini-chat-pane__queued {\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n gap: 4px;\n margin: 4px 0 0;\n}\n.jini-chat-pane__queued-text {\n width: fit-content;\n max-width: 88%;\n padding: 10px 13px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-subtle);\n border: 1px dashed var(--jini-chat-border);\n border-radius: 12px 12px 3px 12px;\n opacity: .68;\n overflow-wrap: break-word;\n}\n.jini-chat-pane__queued-actions {\n display: flex;\n justify-content: flex-end;\n}\n/*\n * A plain-text cancel affordance below the pending bubble, not a primary action \u2014 without this\n * reset a host's own global 'button' styling (background, border, padding, border-radius, bold\n * text) paints it as a full pill-shaped button that visually competes with (and can overlap) the\n * transcript around it \u2014 the exact failure an operator hit live in one host. '.jini-chat-pane\n * button { font: inherit }' near the top of this file already resets font-family/line-height/etc\n * to this pane's own ambient values; font-size/font-weight are redeclared here (not just left at\n * that inherited ambient size) to size this specific control down to a caption scale, well under\n * the bubble's own 14px text.\n * NOTE: this comment avoids backtick quoting on purpose (see this file's own precedent further\n * down) \u2014 the whole stylesheet lives inside a JS template literal, so a literal backtick here\n * would terminate the string, not just this comment.\n */\n.jini-chat-pane__queued-cancel {\n padding: 2px 6px;\n color: var(--jini-chat-muted);\n background: none;\n border: 0;\n border-radius: 6px;\n font-size: 11px;\n font-weight: 400;\n cursor: pointer;\n}\n.jini-chat-pane__queued-cancel:hover {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-border);\n}\n/*\n * Per-message action row (MessageRow.tsx's 'CopyMessageButton', Icon.tsx's 'copy' glyph): quiet,\n * icon-only, always present rather than a hover-only reveal, so keyboard and touch users get the\n * same access a mouse hover would give. The assistant variant carries a thin left rail (its own\n * left border) to read as attached to the turn above it without repeating that message's own\n * chrome; the user variant skips the rail and instead mirrors its bubble's own right alignment.\n * Copy is the only live button today \u2014 'jini-message-actions--assistant' is an intentional\n * extension seam, not the finished row; see that row's own call-site comment in MessageRow.tsx.\n */\n.jini-chat-pane .jini-message-actions {\n display: flex;\n align-items: center;\n gap: 2px;\n margin-top: 6px;\n}\n.jini-chat-pane .jini-message-actions--assistant {\n padding-left: 8px;\n border-left: 2px solid var(--jini-chat-border-soft);\n}\n.jini-chat-pane .jini-message-actions--user { justify-content: flex-end; }\n.jini-chat-pane .jini-message-action-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n padding: 0;\n color: var(--jini-chat-faint);\n background: none;\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-message-action-btn:hover { color: var(--jini-chat-muted); background: var(--jini-chat-subtle); }\n.jini-chat-pane .jini-message-action-btn:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 1px; }\n/* Visually hidden but present in the DOM (never display:none) so the polite live region beside\n each copy button still gets announced \u2014 same clip-rect technique this package already uses for\n its other visually-hidden-but-functional controls. */\n.jini-chat-pane .jini-message-copy-status {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n.jini-chat-pane__controls {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 4;\n padding: 0 16px 14px;\n background: linear-gradient(to bottom, transparent, var(--jini-chat-bg) 22px);\n}\n.jini-chat-pane__suggestions {\n display: flex;\n gap: 8px;\n margin: 0 2px 8px;\n overflow-x: auto;\n}\n.jini-chat-pane__suggestion {\n flex: 0 0 auto;\n max-width: 290px;\n overflow: hidden;\n padding: 7px 11px;\n color: var(--jini-chat-muted);\n background: color-mix(in srgb, var(--jini-chat-panel) 92%, transparent);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n font-size: 11px;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: pointer;\n}\n.jini-chat-pane__error {\n margin: 0 2px 8px;\n padding: 8px 10px;\n color: #8c2f20;\n background: #fff1ed;\n border: 1px solid #f2c8bb;\n border-radius: 8px;\n font-size: 12px;\n}\n.jini-chat-pane__error-action {\n padding: 0;\n color: inherit;\n background: none;\n border: 0;\n font: inherit;\n font-weight: 650;\n text-decoration: underline;\n cursor: pointer;\n}\n.jini-chat-pane__error-action:hover { opacity: .82; }\n.jini-chat-pane__status {\n margin: 0 2px 8px;\n padding: 8px 10px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n font-size: 12px;\n}\n.jini-chat-pane__drop-target {\n position: relative;\n margin: -8px -8px -8px;\n padding: 6px;\n border: 2px dashed transparent;\n border-radius: 16px;\n transition:\n background-color .14s ease,\n border-color .14s ease,\n box-shadow .14s ease;\n}\n.jini-chat-pane__drop-target.is-dragging-files {\n background: color-mix(in srgb, var(--jini-chat-accent-soft) 72%, transparent);\n border-color: var(--jini-chat-accent);\n box-shadow: 0 0 0 1px color-mix(in srgb, var(--jini-chat-accent) 15%, transparent);\n}\n.jini-chat-pane__drop-target.is-dragging-files .jini-composer {\n background: color-mix(in srgb, var(--jini-chat-accent-soft) 58%, var(--jini-chat-panel));\n border-color: color-mix(in srgb, var(--jini-chat-accent) 68%, var(--jini-chat-border));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--jini-chat-accent) 10%, transparent);\n}\n.jini-chat-pane__drop-announcement {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n clip-path: inset(50%);\n}\n.jini-chat-pane .jini-composer {\n position: relative;\n overflow: visible;\n padding: 0;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: none;\n transition: border-color .16s ease, box-shadow .16s ease;\n}\n.jini-chat-pane .jini-composer:focus-within {\n background: var(--jini-chat-panel);\n border-color: color-mix(in srgb, var(--jini-chat-accent) 22%, var(--jini-chat-border-strong));\n box-shadow: 0 1px 2px rgba(26, 25, 22, .06), 0 0 0 1px rgba(201, 100, 66, .06);\n}\n/*\n * Pinned-context zone: whatever a host pins above the composer input (selected plugins, MCP\n * servers, or anything a future host adds -- see 'leadingAccessories''s own doc in 'slots.ts').\n * 'Composer.tsx' only mounts this element when 'slots.leadingAccessories' is a truthy prop value,\n * but a host that always supplies a TRAY COMPONENT rather than conditionally supplying the prop\n * itself (one that internally renders 'null' when nothing is pinned) still leaves an empty node\n * behind -- ':empty' below is what actually collapses THAT case to zero height and zero seam,\n * mirroring '.jini-attachment-tray:empty' a few rules down, which solves the identical problem for\n * the real file-attachment tray.\n *\n * Collapsed (nothing pinned): zero height, zero padding, invisible (0-width) seam -- no reserved\n * space, matching the owner's \"if nil, it doesn't animate\" requirement exactly, since there is\n * nothing here to animate.\n *\n * Populated: grows to a single fixed-height row via 'max-height' (not 'height: auto', which can't\n * transition) and gains a hairline seam using the SAME border/token '.jini-composer-footer' below\n * already uses one zone down -- reads as one more zone of the same control, not a new floating\n * box. The transition runs both directions, so removing the last pinned item gets a matching exit,\n * not an instant cut.\n *\n * 'overflow-x: auto' turns this into a single scrollable row instead of the wrap-and-grow a naive\n * flex row would do with six or more items; '> *' keeps any direct host child (a single tray\n * wrapper, a bare row of buttons, anything) from shrinking to fit, so overflow -- not internal\n * wrapping -- is what kicks in once content is wider than the zone.\n *\n * The \"more content\" cue is a real shadow/vignette, not a plain alpha fade -- an alpha fade toward\n * this zone's own background was tried first and rejected on review: the chip and the zone\n * background are already close in luminance, so fading a chip's OWN pixels toward transparent read\n * as \"something is clipped/broken\", not \"swipe for more\", at a glance with no hover. The four-layer\n * 'background' below is the standard scroll-shadow technique instead: a real 'color-mix'-tinted\n * vignette (darkens in a light theme, lightens in a dark one, since it's mixed from the theme's OWN\n * text color rather than a fixed hex) drawn at each edge -- but each 'scroll'-attached shadow layer\n * is covered by a same-edge 'local'-attached solid-to-transparent layer that scrolls WITH the\n * content, so the shadow is only ever visible on the edges that still have more to reveal: at rest\n * (scrolled to the start) the left shadow is hidden and the right one shows; scroll all the way to\n * the true end and the right shadow hides too. Verified live (not just by reading the technique):\n * scrolling this element from 0 to its max scrollLeft measurably fades the right cover's computed\n * background-position across the full track and the shadow disappears exactly at the end.\n */\n.jini-chat-pane .jini-composer-leading {\n display: flex;\n align-items: center;\n overflow-x: auto;\n overflow-y: hidden;\n max-height: 0;\n padding: 0 12px;\n border-bottom: 0 solid var(--jini-chat-border-soft);\n opacity: 0;\n scrollbar-width: thin;\n transition:\n max-height .22s cubic-bezier(.3, .1, .25, 1),\n padding .22s cubic-bezier(.3, .1, .25, 1),\n border-bottom-width .22s ease,\n opacity .16s ease;\n background-color: var(--jini-chat-subtle);\n background-repeat: no-repeat;\n background-attachment: local, local, scroll, scroll;\n background-position: 0 0, 100% 0, 0 0, 100% 0;\n background-size: 26px 100%, 26px 100%, 20px 100%, 20px 100%;\n background-image:\n linear-gradient(to right, var(--jini-chat-subtle), transparent),\n linear-gradient(to left, var(--jini-chat-subtle), transparent),\n linear-gradient(to right, color-mix(in srgb, var(--jini-chat-text) 30%, transparent), transparent),\n linear-gradient(to left, color-mix(in srgb, var(--jini-chat-text) 30%, transparent), transparent);\n}\n.jini-chat-pane .jini-composer-leading:not(:empty) {\n max-height: 64px;\n padding: 10px 12px;\n border-bottom-width: 1px;\n opacity: 1;\n}\n.jini-chat-pane .jini-composer-leading > * {\n flex: none;\n min-width: max-content;\n}\n@media (prefers-reduced-motion: reduce) {\n .jini-chat-pane .jini-composer-leading { transition: none; }\n}\n.jini-chat-pane .jini-attachment-tray {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n max-height: 88px;\n overflow-y: auto;\n padding: 8px 10px 0;\n scrollbar-width: thin;\n}\n.jini-chat-pane .jini-attachment-tray:empty { display: none; }\n/*\n * The sanctioned reuse path (see '.jini-composer-leading''s own doc above and 'AttachmentTray''s\n * module doc): a host nesting Jini's own tray/chip classes inside the pinned-context zone to match\n * the real attachment tray's look. Un-does the wrap-and-grow rule directly above -- meant for that\n * tray's OWN use one zone down, where wrapping onto a second line is fine -- so the OUTER zone's\n * single scroll axis is the one that wins here instead. The real file-attachment tray is\n * unaffected: it always renders as this zone's sibling ('Composer.tsx'), never its descendant.\n */\n.jini-chat-pane .jini-composer-leading .jini-attachment-tray {\n flex-wrap: nowrap;\n max-height: none;\n overflow: visible;\n padding: 0;\n}\n.jini-chat-pane .jini-attachment-chip {\n display: inline-flex;\n align-items: center;\n box-sizing: border-box;\n min-width: 0;\n max-width: min(100%, 290px);\n height: 36px;\n padding: 3px 4px 3px 5px;\n color: var(--jini-chat-text);\n background: color-mix(in srgb, var(--jini-chat-panel) 88%, var(--jini-chat-subtle));\n border: 1px solid var(--jini-chat-border);\n border-radius: 9px;\n box-shadow: 0 1px 1px rgba(13, 12, 10, .025);\n animation: jini-chat-chip-in .16s ease both;\n}\n@keyframes jini-chat-chip-in {\n from { opacity: 0; transform: translateY(3px) scale(.94); }\n to { opacity: 1; transform: none; }\n}\n@media (prefers-reduced-motion: reduce) {\n .jini-chat-pane .jini-attachment-chip { animation: none; }\n}\n.jini-chat-pane .jini-attachment-chip-body {\n display: flex;\n align-items: center;\n min-width: 0;\n gap: 7px;\n}\n.jini-chat-pane .jini-attachment-chip-icon {\n display: grid;\n flex: 0 0 auto;\n place-items: center;\n width: 26px;\n height: 26px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border-radius: 7px;\n}\n.jini-chat-pane .jini-attachment-chip-icon.is-image {\n color: var(--jini-chat-accent);\n background: var(--jini-chat-accent-soft);\n}\n.jini-chat-pane .jini-attachment-chip-copy {\n display: flex;\n min-width: 0;\n flex-direction: column;\n line-height: 1.15;\n}\n.jini-chat-pane .jini-attachment-chip-name {\n overflow: hidden;\n font-size: 11.5px;\n font-weight: 550;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-attachment-chip-size {\n color: var(--jini-chat-muted);\n font-size: 9.5px;\n}\n.jini-chat-pane .jini-attachment-remove {\n display: grid;\n flex: 0 0 auto;\n place-items: center;\n width: 25px;\n height: 25px;\n margin-left: 4px;\n padding: 0;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 0;\n border-radius: 7px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-attachment-remove:hover,\n.jini-chat-pane .jini-attachment-remove:focus-visible {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n outline: none;\n}\n.jini-chat-pane .jini-composer-input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n min-height: 92px;\n resize: none;\n padding: 12px 14px 8px;\n color: var(--jini-chat-text);\n background: transparent;\n border: 0;\n outline: 0;\n font-size: 13.5px;\n line-height: 1.6;\n}\n/* Shared focus-zoom floor: embeds at every width, all panes on small/touch viewports. */\n.jini-chat-embed .jini-chat-pane .jini-composer-input { font-size: 16px; }\n@media (max-width: 640px), (pointer: coarse) {\n .jini-chat-pane.jini-chat-pane .jini-composer-input { font-size: 16px; }\n}\n.jini-chat-pane .jini-composer-input::placeholder { color: var(--jini-chat-faint); }\n.jini-chat-pane .jini-composer-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n min-height: 42px;\n gap: 6px;\n padding: 6px 8px 8px;\n border-top: 1px solid var(--jini-chat-border-soft);\n}\n.jini-chat-pane .jini-composer-attachment-picker { display: inline-flex; }\n/* Host controls slotted at the start of the footer row, right after the attach/discovery \"+\"\n trigger above -- see ComposerSlots.footerLeadingAccessory's own doc (slots.ts) for why this\n exists as a third footer slot alongside footerAccessories/plusMenuItems. */\n.jini-chat-pane .jini-composer-footer-leading { display: inline-flex; align-items: center; }\n/* NOT 'position: relative' \u2014 the discovery popover (below) anchors off '.jini-composer' itself\n (the nearest positioned ancestor once this wrapper opts out), not off this small trigger-button\n wrapper. Anchoring to the wrapper put the popover's bottom edge at the wrapper's own top edge \u2014\n inside the footer, just below the textarea \u2014 so any popover taller than a couple of rows grew\n upward straight over the textarea and blocked clicks into it (reproduced live: Playwright's own\n click on the textarea failed with \"intercepts pointer events\" while the popover was open). */\n.jini-chat-pane .jini-composer-discovery { display: inline-flex; }\n.jini-chat-pane .jini-composer-discovery-menu,\n.jini-chat-pane .jini-composer-slash-menu {\n position: absolute;\n z-index: 8;\n display: flex;\n flex-direction: column;\n gap: 4px;\n max-height: 280px;\n overflow-y: auto;\n padding: 8px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 10px;\n box-shadow: 0 12px 30px rgb(0 0 0 / 14%);\n}\n/* Both anchor off '.jini-composer' ('bottom: calc(100% + 6px)' = fully above the textarea AND the\n footer, not just above the trigger button) so the popover floats as a clean detached card instead\n of overlapping the input it sits next to. */\n.jini-chat-pane .jini-composer-discovery-menu {\n inset-inline-start: 8px;\n bottom: calc(100% + 6px);\n width: min(280px, calc(100vw - 32px));\n}\n.jini-chat-pane .jini-composer-slash-menu {\n inset-inline: 8px;\n bottom: calc(100% + 6px);\n}\n.jini-chat-pane .jini-composer-discovery-group { display: flex; flex-direction: column; gap: 2px; }\n.jini-chat-pane .jini-composer-discovery-group-label {\n padding: 6px 8px 2px;\n color: var(--jini-chat-faint);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: .08em;\n text-transform: uppercase;\n}\n.jini-chat-pane .jini-composer-discovery-item {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 2px;\n width: 100%;\n padding: 7px 8px;\n color: inherit;\n text-align: start;\n background: transparent;\n border: 0;\n border-radius: 7px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-composer-discovery-item:hover {\n background: var(--jini-chat-panel);\n}\n.jini-chat-pane .jini-composer-discovery-item.is-active {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-accent-soft);\n outline: 1px solid var(--jini-chat-accent);\n outline-offset: -1px;\n box-shadow: inset 3px 0 0 var(--jini-chat-accent);\n}\n.jini-chat-pane .jini-composer-discovery-item small { color: var(--jini-chat-muted); }\n/* The full description used to print under every row unconditionally, which is why the palette\n grew to dozens of multi-line rows on a bare \"/\" \u2014 kept in the accessibility tree at rest (never\n 'display: none', which most screen readers drop from that tree) so 'aria-describedby' keeps\n resolving whether or not anything is hovering.\n FIXED (was: opacity-only toggle, THEN an absolutely-positioned overlay) \u2014 two owner-reported\n bugs in sequence on the same element:\n 1. 'opacity: 0' alone hides a box visually but does not remove it from layout, so an\n absolutely-positioned tooltip sized to its full natural content height (routinely 50-260px\n for a real description) still counted toward its scrolling ancestor's scrollable overflow\n region while invisible ('scrollHeight' measured 501px against a 242px 'clientHeight' for a\n 6-row list), leaving a blank scrollable gap after the last real row. Fixed by collapsing the\n box itself at rest ('max-height: 0; overflow: hidden;'), not just hiding it \u2014 carried\n forward below.\n 2. That fix still left this 'position: absolute; top: 100%' of its own row (formerly anchored\n via 'position: relative' on '.jini-composer-discovery-item' above, now removed \u2014 nothing\n anchors off it anymore): 'z-index' only controls paint order, not which box a box is\n confined to, so on hover/focus the expanded tooltip painted directly over whichever row\n happened to sit below it in the SAME scrollable list (owner report: hovering \"UI/UX Design\n (Skill)\" covered the \"/mcp\" entry beneath it \u2014 not a stacking-order problem, a containment\n one). Fixed by dropping 'position: absolute' entirely: this is now a normal in-flow child of\n the row's own 'flex-direction: column' box, still collapsed to negligible size at rest via\n 'max-height: 0; overflow: hidden;', but on hover/focus it grows the ROW's own height instead\n of floating a detached box over the next row \u2014 the popover's flex column (and every row\n below) gets pushed down to make room, so it can never occlude a sibling item. This also\n retires the panel-styled \"floating card\" look (background/border/shadow): an in-flow caption\n reads correctly with the row's own muted small-text convention above, and dropping it removes\n the last reason this needed its own stacking context. 'pointer-events: none' is kept for\n parity even though nothing sits under it to protect anymore. */\n.jini-chat-pane .jini-composer-discovery-description {\n width: 100%;\n max-height: 0;\n padding: 0;\n overflow: hidden;\n color: var(--jini-chat-muted);\n font-size: 12px;\n font-weight: 400;\n line-height: 1.4;\n white-space: normal;\n opacity: 0;\n pointer-events: none;\n transition: opacity .12s ease;\n}\n.jini-chat-pane .jini-composer-discovery-item:hover .jini-composer-discovery-description,\n.jini-chat-pane .jini-composer-discovery-item:focus-visible .jini-composer-discovery-description {\n max-height: none;\n padding-top: 2px;\n overflow: visible;\n opacity: 1;\n}\n/* '<code>'/'<small>' both default to the browser's UA styling (monospace at an unrelated size)\n with nothing here overriding it \u2014 computed font-SIZE happened to already match the row label\n (13.5px), so the visible mismatch is font-FAMILY: raw monospace next to the label's sans-serif\n stack reads as a different, larger type scale even at equal size. Inheriting the row's own font\n puts both on one consistent scale. */\n.jini-chat-pane .jini-composer-slash-argument,\n.jini-chat-pane .jini-composer-slash-confirm-badge {\n font-family: inherit;\n font-size: inherit;\n font-weight: 400;\n color: var(--jini-chat-muted);\n}\n.jini-chat-pane .jini-composer-file-input {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n clip-path: inset(50%);\n}\n.jini-chat-pane .jini-composer-attach {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n padding: 0;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 1px solid transparent;\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-composer-attach:hover:not(:disabled) {\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border-color: var(--jini-chat-border);\n}\n.jini-chat-pane .jini-composer-attach:disabled { opacity: .45; cursor: default; }\n/* The working-directory trigger button reuses '.jini-composer-attach' itself (Composer.tsx) rather\n than a class duplicated here, so it is byte-identical in size/hit-area/hover/focus to its\n neighbour rather than an approximation that could drift out of sync. Only the wrapper and its\n popover are new. NOT 'position: relative' on the wrapper, for the same reason noted above\n '.jini-composer-discovery': the popover's un-measured first paint (before the layout effect sets\n its 'position: fixed' inline style) falls back to this rule's 'position: absolute', which needs\n '.jini-composer' itself \u2014 the nearest positioned ancestor \u2014 not this wrapper, to anchor against. */\n.jini-chat-pane .jini-composer-workdir { display: inline-flex; }\n.jini-chat-pane .jini-composer-workdir-panel {\n position: absolute;\n z-index: 8;\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px;\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 10px;\n box-shadow: 0 12px 30px rgb(0 0 0 / 14%);\n}\n.jini-chat-pane .jini-composer-workdir-input {\n flex: 1;\n min-width: 160px;\n padding: 6px 8px;\n color: var(--jini-chat-text);\n font: inherit;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 6px;\n}\n.jini-chat-pane .jini-composer-workdir-confirm {\n padding: 6px 10px;\n color: white;\n white-space: nowrap;\n background: var(--jini-chat-text-strong);\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n/* `.jini-icon-spin` is `<Icon name=\"spinner\">` (the tool-row \"Running\" badge). Its keyframes used to\n live only in reference.css, which no host imports, so the badge sat still. */\n.jini-composer-spinner,\n.jini-runtime-spinner,\n.jini-icon-spin { animation: jini-chat-spin .8s linear infinite; }\n@keyframes jini-chat-spin { to { transform: rotate(360deg); } }\n.jini-chat-pane .jini-composer-footer-accessories {\n display: flex;\n margin-left: auto;\n min-width: 0;\n}\n.jini-chat-pane .jini-composer-send-group {\n display: flex;\n flex-shrink: 0;\n gap: 6px;\n}\n/* A footer with no attach/accessory controls before the send group (a public visitor chat) would\n leave the lone send button at the start under the footer's space-between; keep it at the end. */\n.jini-chat-pane .jini-composer-send-group:first-child { margin-left: auto; }\n.jini-chat-pane .jini-composer-send {\n display: grid;\n place-items: center;\n width: 32px;\n height: 32px;\n padding: 0;\n color: white;\n background: var(--jini-chat-text-strong);\n border: 0;\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-composer-send:disabled {\n color: #aaa59e;\n background: #ece8e2;\n cursor: default;\n}\n.jini-chat-pane__workdir {\n display: flex;\n align-items: center;\n gap: 7px;\n min-width: 0;\n padding: 5px 4px 0;\n color: var(--jini-chat-muted);\n font-size: 13px;\n}\n.jini-chat-pane__workdir code {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-picker {\n position: relative;\n display: inline-flex;\n align-items: flex-start;\n}\n.jini-chat-pane .jini-working-dir-trigger-row {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n.jini-chat-pane .jini-working-dir-trigger {\n appearance: none;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 4px 6px;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 0;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-working-dir-trigger:hover,\n.jini-chat-pane .jini-working-dir-trigger:focus-visible {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n outline: none;\n}\n.jini-chat-pane .jini-working-dir-trigger.invalid { color: var(--danger); }\n.jini-chat-pane .jini-working-dir-trigger-icon { flex: 0 0 auto; opacity: .75; }\n.jini-chat-pane .jini-working-dir-trigger.invalid .jini-working-dir-trigger-icon {\n color: var(--danger);\n opacity: 1;\n}\n.jini-chat-pane .jini-working-dir-trigger-label {\n max-width: 240px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-trigger-chevron { flex: 0 0 auto; opacity: .55; }\n.jini-chat-pane .jini-working-dir-panel,\n.jini-chat-pane .jini-working-dir-flyout {\n position: absolute;\n z-index: 90;\n display: flex;\n flex-direction: column;\n min-width: 210px;\n padding: 5px;\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border-strong);\n border-radius: 12px;\n box-shadow: 0 1px 2px rgba(0, 0, 0, .04), 0 10px 28px -10px rgba(0, 0, 0, .18);\n}\n.jini-chat-pane .jini-working-dir-panel {\n top: calc(100% + 6px);\n left: 0;\n}\n.jini-chat-pane .jini-working-dir-panel.up {\n top: auto;\n bottom: calc(100% + 6px);\n}\n.jini-chat-pane .jini-working-dir-item {\n appearance: none;\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n min-height: 32px;\n padding: 0 9px;\n color: var(--jini-chat-text-strong);\n background: transparent;\n border: 0;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n text-align: left;\n cursor: pointer;\n}\n.jini-chat-pane .jini-working-dir-item:hover,\n.jini-chat-pane .jini-working-dir-item:focus-visible,\n.jini-chat-pane .jini-working-dir-recent-item:hover,\n.jini-chat-pane .jini-working-dir-recent-item:focus-visible {\n background: var(--jini-chat-subtle);\n outline: none;\n}\n.jini-chat-pane .jini-working-dir-item-icon { flex: 0 0 auto; opacity: .8; }\n.jini-chat-pane .jini-working-dir-item > span {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-item-chevron { margin-left: auto; opacity: .55; }\n.jini-chat-pane .jini-working-dir-submenu-row { position: relative; }\n.jini-chat-pane .jini-working-dir-flyout {\n top: -5px;\n left: 100%;\n z-index: 91;\n min-width: 220px;\n max-width: 320px;\n margin-left: 4px;\n}\n.jini-chat-pane .jini-working-dir-flyout.up { top: auto; bottom: -5px; }\n.jini-chat-pane .jini-working-dir-flyout::before {\n position: absolute;\n top: 0;\n left: -8px;\n width: 8px;\n height: 100%;\n content: \"\";\n}\n.jini-chat-pane .jini-working-dir-recent-item {\n appearance: none;\n display: grid;\n grid-template-rows: auto auto;\n grid-template-columns: auto 1fr;\n align-items: center;\n width: 100%;\n min-height: 36px;\n padding: 4px 9px;\n color: var(--jini-chat-text-strong);\n background: transparent;\n border: 0;\n border-radius: 8px;\n font: inherit;\n text-align: left;\n cursor: pointer;\n column-gap: 9px;\n}\n.jini-chat-pane .jini-working-dir-recent-item .jini-working-dir-item-icon {\n grid-row: 1 / span 2;\n}\n.jini-chat-pane .jini-working-dir-recent-name,\n.jini-chat-pane .jini-working-dir-recent-path {\n grid-column: 2;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-recent-name { font-size: 13px; }\n.jini-chat-pane .jini-working-dir-recent-path {\n color: var(--jini-chat-muted);\n font-size: 11px;\n opacity: .7;\n}\n.jini-chat-pane .jini-working-dir-empty {\n padding: 8px 10px;\n color: var(--jini-chat-muted);\n font-size: 12px;\n white-space: nowrap;\n opacity: .7;\n}\n.jini-runtime-picker { position: relative; }\n.jini-runtime-trigger {\n display: inline-grid;\n grid-template-columns: 24px 14px;\n align-items: center;\n gap: 7px;\n height: 32px;\n min-width: 0;\n padding: 3px 7px 3px 5px;\n color: var(--jini-chat-text);\n background: transparent;\n border: 0;\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-runtime-trigger:hover { background: var(--jini-chat-subtle); }\n.jini-runtime-trigger__copy { display: none; }\n.jini-runtime-trigger__copy strong {\n overflow: hidden;\n max-width: 122px;\n font-size: 11px;\n font-weight: 650;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-runtime-trigger__copy small {\n overflow: hidden;\n max-width: 122px;\n color: var(--jini-chat-muted);\n font-size: 9px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-runtime-trigger__chevron { color: var(--jini-chat-muted); font-size: 12px; }\n.jini-runtime-agent-icon.agent-icon { flex: 0 0 auto; object-fit: contain; }\n.jini-runtime-agent-icon.agent-icon-mono {\n display: inline-block;\n background-color: currentColor;\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-position: center;\n mask-position: center;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n.jini-runtime-agent-icon.agent-icon-fallback {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: #74716b;\n background: #f4f5f7;\n border: 1px solid #e1e5eb;\n border-radius: 6px;\n font-weight: 700;\n}\n.jini-runtime-popover {\n --jini-chat-panel: #fdfcfa;\n --jini-chat-text: #1a1916;\n --jini-chat-text-strong: #0d0c0a;\n --jini-chat-muted: #74716b;\n --jini-chat-faint: #b3b0a8;\n --jini-chat-border: #e1e5eb;\n --jini-chat-border-soft: #edf0f4;\n --jini-chat-subtle: #f4f5f7;\n box-sizing: border-box;\n padding: 8px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: 0 18px 46px rgba(13, 12, 10, .15), 0 2px 8px rgba(13, 12, 10, .06);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Microsoft YaHei UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n font-size: 13.5px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n}\n.jini-runtime-popover__head {\n display: flex;\n flex-direction: column;\n gap: 3px;\n padding: 10px 12px 12px;\n border-bottom: 1px solid var(--jini-chat-border-soft);\n}\n.jini-runtime-popover__head strong { color: var(--jini-chat-text-strong); font-size: 14px; font-weight: 650; }\n.jini-runtime-popover__head span { color: var(--jini-chat-muted); font-size: 12px; }\n.jini-runtime-mode,\n.jini-runtime-agent,\n.jini-runtime-rescan {\n display: flex;\n align-items: center;\n gap: 11px;\n box-sizing: border-box;\n width: 100%;\n min-height: 40px;\n padding: 8px 11px;\n color: var(--jini-chat-text);\n background: transparent;\n border: 0;\n border-radius: 8px;\n text-align: left;\n cursor: pointer;\n}\n.jini-runtime-mode:hover,\n.jini-runtime-agent:hover:not(:disabled),\n.jini-runtime-rescan:hover:not(:disabled) { background: var(--jini-chat-subtle); }\n.jini-runtime-mode.is-active { font-weight: 650; }\n.jini-runtime-mode:disabled,\n.jini-runtime-agent:disabled,\n.jini-runtime-rescan:disabled { color: var(--jini-chat-faint); cursor: default; }\n.jini-runtime-mode__meta,\n.jini-runtime-agent__status {\n margin-left: auto;\n color: var(--jini-chat-muted);\n font-size: 12px;\n white-space: nowrap;\n}\n.jini-runtime-check { flex: 0 0 auto; color: var(--jini-chat-text); }\n/* Matches .jini-message-attachment-chip/.jini-md-table-expand's pill chrome (same\n background/border/radius formula) so this reads as the same badge language already used\n elsewhere in the pane, not a new one. Sits between the agent name and its status column \u2014\n flex: 0 0 auto keeps it from stretching or shrinking when the row is tight. */\n.jini-runtime-agent__badge {\n flex: 0 0 auto;\n padding: 2px 8px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n font-size: 10px;\n font-weight: 500;\n white-space: nowrap;\n}\n.jini-runtime-section-label {\n padding: 12px 11px 5px;\n color: var(--jini-chat-faint);\n font-size: 11px;\n font-weight: 650;\n letter-spacing: .08em;\n text-transform: uppercase;\n}\n.jini-runtime-agent.is-active { background: transparent; }\n.jini-runtime-agent__copy {\n display: flex;\n flex: 1;\n flex-direction: column;\n min-width: 0;\n}\n.jini-runtime-agent__copy strong { font-size: 13.5px; font-weight: 500; }\n.jini-runtime-agent__copy small {\n overflow: hidden;\n color: #8b857e;\n font-size: 10px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-runtime-empty { padding: 10px 11px; color: var(--jini-chat-muted); font-size: 12px; }\n.jini-runtime-models {\n display: flex;\n flex-direction: column;\n gap: 9px;\n margin-top: 5px;\n padding: 10px 11px 8px;\n border-top: 1px dashed var(--jini-chat-border);\n}\n.jini-runtime-select {\n display: flex;\n flex-direction: column;\n gap: 5px;\n color: var(--jini-chat-muted);\n font-size: 13px;\n}\n.jini-runtime-select select {\n box-sizing: border-box;\n width: 100%;\n min-height: 40px;\n padding: 7px 10px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n}\n/*\n * The BYOK model, rendered as a value rather than a control \u2014 see RuntimeByokDetails for why it\n * is not a disabled select. It occupies the same box the sibling selects do so the row still\n * reads as part of the same list, but carries no border: a bordered box with no affordance is\n * exactly the \"looks editable, isn't\" shape the plain text exists to avoid.\n */\n/*\n * The BYOK model list, which CustomSelect portals to document.body. runtimePopoverPosition puts\n * the runtime popover at z-index 1000; the shared .jini-select-menu default is 60, which is right\n * everywhere else and puts this menu behind the very popover that opened it. One above the\n * popover, not far above: it must clear its own surface and nothing else.\n */\n.jini-select-menu.jini-runtime-model-menu {\n z-index: 1001;\n}\n.jini-runtime-byok-model strong {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n min-height: 40px;\n padding: 7px 10px;\n color: var(--jini-chat-text);\n font-weight: 600;\n word-break: break-all;\n}\n/*\n * Tool-call cards (ToolCard.tsx) and the per-turn usage summary (MessageRow.tsx) ship no CSS of\n * their own \u2014 see this file's own header note on why the package supplies a default theme at all.\n * Collapsed by default (.op-card-head is the only always-visible part), so a run with a dozen tool\n * calls reads as a dozen one-line rows, not a page of raw JSON.\n */\n.jini-chat-pane .jini-message-tools {\n display: flex;\n flex-direction: column;\n gap: 6px;\n width: 100%;\n}\n.jini-chat-pane .op-card {\n border: 1px solid var(--jini-chat-border);\n border-radius: var(--radius);\n background: var(--jini-chat-subtle);\n overflow: hidden;\n font-size: 13px;\n}\n.jini-chat-pane .op-card-head {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 7px 10px;\n background: none;\n border: 0;\n font: inherit;\n text-align: left;\n cursor: pointer;\n color: inherit;\n}\n.jini-chat-pane .op-status { display: inline-flex; flex-shrink: 0; }\n.jini-chat-pane .op-status-ok { color: #4e875f; }\n.jini-chat-pane .op-status-error { color: var(--danger); }\n.jini-chat-pane .op-status-running { color: var(--jini-chat-accent); }\n.jini-chat-pane .op-title { font-weight: 600; flex-shrink: 0; }\n.jini-chat-pane .shimmer-text { animation: jini-chat-shimmer 1.6s ease-in-out infinite; }\n@keyframes jini-chat-shimmer { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }\n@media (prefers-reduced-motion: reduce) { .jini-chat-pane .shimmer-text { animation: none; opacity: .6; } }\n.jini-chat-pane .op-meta {\n color: var(--jini-chat-muted);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n min-width: 0;\n}\n.jini-chat-pane .op-expand-chev { flex-shrink: 0; color: var(--jini-chat-faint); }\n/* Collapsed by default: 0 max-height clips the inner content, no display:none \u2014 keeps the height\n transition instead of an instant jump cut. */\n.jini-chat-pane .accordion-collapsible { max-height: 0; overflow: hidden; transition: max-height .15s ease; }\n.jini-chat-pane .accordion-collapsible.open { max-height: 480px; overflow-y: auto; }\n.jini-chat-pane .accordion-collapsible-inner { padding: 0 10px 10px; }\n.jini-chat-pane .op-card-detail { display: flex; flex-direction: column; gap: 6px; }\n.jini-chat-pane .op-path,\n.jini-chat-pane .op-command,\n.jini-chat-pane .op-output {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 6px;\n padding: 6px 8px;\n margin: 0;\n white-space: pre-wrap;\n word-break: break-word;\n}\n/* The \"Waiting for the result or answer\u2026\" / \"Finished with no output.\" line in an expanded tool row (ToolCard.tsx's ToolCallDetail). */\n.jini-chat-pane .op-detail-note { margin: 0; font-size: 12px; color: var(--jini-chat-muted); }\n.jini-chat-pane .op-open {\n border: 1px solid var(--jini-chat-border-strong);\n border-radius: 6px;\n background: var(--jini-chat-panel);\n padding: 2px 8px;\n font-size: 11px;\n cursor: pointer;\n}\n/* The \"Done \u00B7 6m 29s \u00B7 2612 out \u00B7 $0.4028\" line \u2014 real numbers from the run's own kind:'usage' event. */\n.jini-chat-pane .jini-message-usage {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n color: var(--jini-chat-muted);\n margin-top: 2px;\n}\n.jini-chat-pane .jini-message-usage-dot { font-size: 8px; color: #4e875f; }\n/* toolCallDisplay 'collapsed' (ToolCallGroup.tsx): one quiet line per turn, cards open under it. */\n.jini-chat-pane .jini-tool-group { display: flex; flex-direction: column; gap: 6px; width: 100%; }\n.jini-chat-pane .jini-tool-group__toggle {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n align-self: flex-start;\n max-width: 100%;\n padding: 2px 0;\n background: none;\n border: 0;\n font: inherit;\n font-size: 12px;\n color: var(--jini-chat-muted);\n cursor: pointer;\n}\n.jini-chat-pane .jini-tool-group__toggle:hover { color: inherit; }\n.jini-chat-pane .jini-tool-group__toggle:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 2px; border-radius: 4px; }\n.jini-chat-pane .jini-tool-group__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.jini-chat-pane .jini-tool-group[data-failed=\"true\"] .jini-tool-group__toggle { color: var(--danger); }\n/* showThinking (MessageRow.tsx's ThinkingBlock): the model's reasoning, folded, set apart from the answer. */\n.jini-chat-pane .jini-message-thinking { font-size: 12px; color: var(--jini-chat-muted); }\n.jini-chat-pane .jini-message-thinking > summary { cursor: pointer; width: fit-content; }\n.jini-chat-pane .jini-message-thinking__text {\n margin-top: 4px;\n padding-left: 10px;\n border-left: 2px solid var(--jini-chat-border);\n white-space: pre-wrap;\n word-break: break-word;\n}\n/*\n * Fenced code block ('Markdown.tsx''s 'renderBlock' case 'code') and inline 'code' span visual\n * treatment \u2014 previously entirely unstyled (bare UA-default '<pre><code>', no background, border,\n * or padding; a downstream admin host's own stylesheet already flagged this gap in a note next to\n * its unrelated horizontal-scroll fix for the same element). Lives HERE, in the package's own\n * injected default theme, rather than in a host's stylesheet: a host CAN already remap the exact\n * '--jini-chat-*' custom properties this rule reads (one such host's '.admin-chat-dock .jini-chat-\n * pane' block already does, for every other rule in this file), so putting the fix here makes every\n * Jini host look correct by default AND lets a host match its own palette for free, with no new\n * host-side override rule needed at all \u2014 the reverse (host-only CSS) would leave every other\n * embedder of '@jini-ai/chat' with the same unstyled '<pre>' this fix exists to correct.\n * 'max-width'/'overflow-x' here duplicate (harmlessly) a narrower fix that same host's own\n * '.jini-message-content pre' rule already carries for its own fixed-width dock specifically \u2014 this\n * package cannot assume every host has that rule, so a real host-agnostic default belongs here too.\n * NOTE: this comment avoids backtick quoting on purpose \u2014 this whole stylesheet lives inside a JS\n * template literal, so a literal backtick here would terminate the string, not just this comment.\n */\n.jini-chat-pane .jini-message-content pre {\n margin: 6px 0;\n max-width: 100%;\n overflow-x: auto;\n padding: 10px 12px;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12.5px;\n line-height: 1.5;\n}\n.jini-chat-pane .jini-message-content pre code {\n padding: 0;\n background: none;\n border: 0;\n color: inherit;\n font: inherit;\n}\n.jini-chat-pane .jini-message-content :not(pre) > code {\n padding: .15em .4em;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 4px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: .9em;\n}\n/*\n * Copy affordance for a fenced code block ('Markdown.tsx''s 'CodeBlock'/'CopyCodeButton'). The\n * button lives in its own toolbar row fused to the TOP of the '<pre>' below, not overlaid on top\n * of the code itself \u2014 an overlay would sit over whatever text happens to render at that corner,\n * and '<pre>' already carries its own horizontal scrollbar along its own bottom edge, so a row\n * above it can never collide with that scrollbar either. '.jini-md-code-block' takes over the\n * '<pre>' rule's own top/bottom margin above so the two don't stack, and the toolbar/pre pair\n * share one rounded box (toolbar keeps the top corners, '<pre>' below loses them) instead of two\n * separately-bordered boxes stacked on each other.\n */\n.jini-chat-pane .jini-message-content .jini-md-code-block {\n margin: 6px 0;\n}\n.jini-chat-pane .jini-message-content .jini-md-code-block pre {\n margin: 0;\n border-top: 0;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.jini-chat-pane .jini-md-code-toolbar {\n display: flex;\n justify-content: flex-end;\n padding: 3px;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-bottom: 0;\n border-radius: 8px 8px 0 0;\n}\n.jini-chat-pane .jini-md-code-copy {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 22px;\n padding: 0;\n color: var(--jini-chat-faint);\n background: none;\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-md-code-copy:hover { color: var(--jini-chat-muted); background: var(--jini-chat-border); }\n.jini-chat-pane .jini-md-code-copy:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 1px; }\n/* Visually hidden but present in the DOM (never display:none) so this polite live region still\n gets announced \u2014 same clip-rect technique 'jini-message-copy-status' above already uses. */\n.jini-chat-pane .jini-md-code-copy-status {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n/*\n * GFM pipe tables ('Markdown.tsx''s 'TableBlock') \u2014 same reasoning as the code-block rules directly\n * above for living in the package's own default theme rather than a host stylesheet. '.jini-md-\n * table-wrap' gets its own horizontal scrollbar so a wide table scrolls internally instead of\n * dragging '.jini-message-list' sideways with it (the exact failure mode that same downstream host's\n * stylesheet documents for the code-block case \u2014 the same fix shape applies here). The \"Expand\n * table\" button only renders ('TableBlock''s own logic) once the table has genuinely overflowed\n * that wrap.\n */\n.jini-md-table-wrap {\n max-width: 100%;\n overflow-x: auto;\n}\n.jini-md-table {\n border-collapse: collapse;\n font-size: 12.5px;\n}\n/*\n * 'min-width' here, not on '.jini-md-table' itself, and picked per column rather than as one flat\n * table-wide number -- a flat table minimum sized for a typical 2-column table would leave a\n * 5-column table crushed just as badly as before (same failure this comment's neighbor above\n * describes), while one sized for 5 columns would force pointless overflow on a simple 2-column\n * table. Per-cell 'min-width' scales the effective table floor with the real column count instead.\n * 130px was picked empirically, not guessed: measured (real Chromium, not jsdom -- 'scrollWidth'/\n * 'clientWidth' are unimplemented there) against this package's narrowest known real host, an\n * admin chat dock fixed at 380px wide ('admin-chat-dock' in that host's own stylesheet), which\n * nets out to roughly 238px of usable width once that dock's own message-list padding, the 80%\n * message-bubble cap, and this content's own padding are all applied. Below 120px per column the\n * table still fits inside those 238px and never overflows -- the exact bug this fix exists to\n * correct. 130px was chosen just above that measured 120px crossover (a small margin so it clears\n * reliably rather than sitting exactly on the boundary) and reads as a genuinely defensible per-\n * column floor on its own terms too: with this rule's own 6px/10px cell padding subtracted, it\n * leaves about 110px of text room, roughly 14-16 characters per wrapped line at this rule's 12.5px\n * font -- enough that a table cell wraps in short phrases instead of one word per line, which is\n * the readability floor this fix is actually chasing. One real consequence, verified rather than\n * hand-waved: at this width, ANY 2-column table in that narrow dock now overflows and gets the\n * \"Expand table\" affordance, including ones with short cell content -- there is no per-cell\n * min-width value that can overflow a long-prose 2-column table without also overflowing a short\n * one at the same column count, because the browser's own auto-layout table-width algorithm sizes\n * the table to the greater of its container's width and the sum of each column's own minimum --\n * content length past that minimum only changes how much a column wraps internally, never whether\n * the table exceeds its wrap. Accepted here: in a surface already this narrow, showing \"Expand\n * table\" a little more eagerly is the better failure mode than silently crushing a table's own\n * content illegibly, which is the state this whole rule exists to fix.\n */\n.jini-md-table th,\n.jini-md-table td {\n padding: 6px 10px;\n min-width: 130px;\n border: 1px solid var(--jini-chat-border);\n text-align: left;\n}\n.jini-md-table th {\n background: var(--jini-chat-subtle);\n color: var(--jini-chat-text-strong);\n font-weight: 650;\n}\n.jini-md-table-expand {\n margin-top: 4px;\n padding: 4px 10px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n font-size: 11px;\n cursor: pointer;\n}\n.jini-md-table-expand:hover { color: var(--jini-chat-text-strong); }\n.jini-md-table-modal {\n max-width: min(90vw, 900px);\n max-height: 80vh;\n overflow: auto;\n padding: 20px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: 0 18px 46px rgba(13, 12, 10, .15), 0 2px 8px rgba(13, 12, 10, .06);\n}\n.jini-md-table-modal::backdrop {\n background: rgba(0, 0, 0, .4);\n}\n.jini-md-table-modal-close {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 28px;\n height: 28px;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 0;\n border-radius: 6px;\n font-size: 16px;\n line-height: 1;\n cursor: pointer;\n}\n.jini-md-table-modal-close:hover {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n}\n@media (max-width: 560px) {\n .jini-chat-pane__header { padding-inline: 16px; }\n .jini-chat-pane .jini-message-list { padding-inline: 16px; }\n .jini-chat-pane__controls { padding-inline: 10px; }\n}\n/*\n * Attachment preview modal (MessageRow.tsx's chip -> AttachmentPreviewModal.tsx). Same\n * native-<dialog> shape as .jini-md-table-modal above (own box sized to content, not viewport;\n * ::backdrop dims the page), kept as its own class rather than reusing that one because the two\n * differ in intent: a table pop-out is a fixed-chrome expansion of content already on screen, this\n * is a general image/text/metadata viewer with its own header and a dedicated close control.\n */\n.jini-attachment-preview-dialog {\n display: flex;\n flex-direction: column;\n width: min(90vw, 720px);\n max-height: 80vh;\n padding: 0;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: 0 18px 46px rgba(13, 12, 10, .15), 0 2px 8px rgba(13, 12, 10, .06);\n}\n.jini-attachment-preview-dialog::backdrop {\n background: rgba(0, 0, 0, .4);\n}\n.jini-attachment-preview-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--jini-chat-border-soft);\n}\n.jini-attachment-preview-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 650;\n color: var(--jini-chat-text-strong);\n}\n.jini-attachment-preview-close {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 26px;\n height: 26px;\n margin-left: auto;\n padding: 0;\n color: var(--jini-chat-muted);\n background: none;\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n.jini-attachment-preview-close:hover {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n}\n.jini-attachment-preview-body {\n display: flex;\n flex: 1;\n min-height: 0;\n align-items: center;\n justify-content: center;\n padding: 16px;\n overflow: auto;\n}\n.jini-attachment-preview-image {\n display: block;\n max-width: 100%;\n max-height: calc(80vh - 90px);\n object-fit: contain;\n margin: auto;\n}\n.jini-attachment-preview-text {\n width: 100%;\n max-height: calc(80vh - 90px);\n margin: 0;\n padding: 12px;\n overflow: auto;\n color: var(--jini-chat-text);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border-soft);\n border-radius: 8px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n line-height: 1.5;\n white-space: pre-wrap;\n word-break: break-word;\n}\n.jini-attachment-preview-truncated {\n width: 100%;\n margin-top: 8px;\n color: var(--jini-chat-muted);\n font-size: 11px;\n text-align: center;\n}\n.jini-attachment-preview-loading {\n color: var(--jini-chat-muted);\n font-size: 13px;\n font-style: italic;\n}\n.jini-attachment-preview-meta {\n display: flex;\n flex-direction: column;\n gap: 6px;\n width: 100%;\n max-width: 360px;\n color: var(--jini-chat-text);\n font-size: 13px;\n}\n.jini-attachment-preview-meta-row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n.jini-attachment-preview-meta-label {\n color: var(--jini-chat-faint);\n}\n.jini-attachment-preview-meta-note {\n margin-top: 6px;\n color: var(--jini-chat-muted);\n font-size: 12px;\n}\n";
1
+ export declare const CHAT_PANE_STYLES = "\n.jini-chat-pane {\n --jini-chat-bg: #faf9f7;\n --jini-chat-panel: #fdfcfa;\n --jini-chat-text: #1a1916;\n --jini-chat-text-strong: #0d0c0a;\n --jini-chat-muted: #74716b;\n --jini-chat-faint: #b3b0a8;\n --jini-chat-border: #e1e5eb;\n --jini-chat-border-strong: #c9d0da;\n --jini-chat-border-soft: #edf0f4;\n --jini-chat-subtle: #f4f5f7;\n --jini-chat-accent: #c96442;\n --jini-chat-accent-soft: #fbeee5;\n --jini-chat-danger: #e5484d;\n --jini-chat-radius: 8px;\n --jini-chat-radius-lg: 12px;\n --bg-panel: var(--jini-chat-panel);\n --bg-subtle: var(--jini-chat-subtle);\n --border: var(--jini-chat-border);\n --border-strong: var(--jini-chat-border-strong);\n --text: var(--jini-chat-text);\n --text-strong: var(--jini-chat-text-strong);\n --text-muted: var(--jini-chat-muted);\n --danger: var(--jini-chat-danger);\n --radius: var(--jini-chat-radius);\n --radius-lg: var(--jini-chat-radius-lg);\n position: relative;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr) auto;\n min-width: 0;\n min-height: 0;\n height: 100%;\n /* clip after the hidden fallback: a hidden box still scrolls programmatically (a focus() or\n scrollIntoView() inside it), which shifts the pinned composer up and leaves blank pane below. */\n overflow: hidden;\n overflow: clip;\n color: var(--jini-chat-text);\n background: var(--jini-chat-bg);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Microsoft YaHei UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n font-size: 13.5px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.jini-chat-pane button,\n.jini-runtime-popover button,\n.jini-runtime-popover select {\n font: inherit;\n}\n.jini-chat-pane__header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n min-height: 74px;\n padding: 18px 22px 14px;\n border-bottom: 1px solid var(--jini-chat-border-soft);\n}\n.jini-chat-pane__heading { min-width: 0; }\n.jini-chat-pane__eyebrow {\n display: block;\n margin-bottom: 4px;\n color: var(--jini-chat-muted);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 10px;\n letter-spacing: .1em;\n text-transform: uppercase;\n}\n.jini-chat-pane__title {\n overflow: hidden;\n margin: 0;\n font-size: 21px;\n font-weight: 650;\n letter-spacing: -.025em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane__new-thread {\n padding: 8px 12px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-chat-pane__body {\n position: relative;\n min-height: 0;\n overflow: hidden;\n overflow: clip;\n}\n.jini-chat-pane .jini-message-list {\n height: 100%;\n overflow-y: auto;\n /*\n * .jini-chat-pane__controls is an absolutely-positioned overlay (not a flex/grid sibling that\n * pushes this list up), so its real height has to be reserved here instead. This used to be a\n * flat 240px guess -- and had already gone stale once before that: it started at 170px, a live\n * render outgrew it (composer + footer + working-directory row alone measured 179.75px, clipping\n * the last message's tail under the overlay), and 240px was chosen as a margin above THAT\n * measurement, not a value anything keeps in sync. useChatPaneControlsHeight now measures the\n * overlay's real height live via ResizeObserver and publishes it as\n * --jini-chat-controls-height on the pane root, so this reservation tracks the composer's\n * actual content (an attachment tray, a wrapped multi-line draft, a taller runtime-picker\n * popover) instead of a number that is only ever correct until the next thing that grows it.\n * 240px survives as the fallback for the rare environment without ResizeObserver (SSR; a test\n * harness that never polyfills it) -- everywhere else, the calc() wins.\n */\n padding: 20px 22px calc(var(--jini-chat-controls-height, 240px) + 24px);\n scrollbar-width: thin;\n}\n.jini-chat-pane .jini-message {\n margin-bottom: 20px;\n color: var(--jini-chat-text);\n font-size: 14px;\n line-height: 1.62;\n}\n/*\n * Off-screen rows skip style, layout and paint. Owner report 2026-10-11: typing in a long chat\n * lagged. React was not the cost (no row re-renders per key); the browser was. A keystroke's DOM\n * change can invalidate style for the whole document (a host dock's :has() rule did),\n * and a transcript with ~2,000 tool cards and 3.5 MB of tool output then cost ~50 ms of style\n * recalc per key. With this, only the rows on screen are recalculated (~8 ms). The DOM stays\n * whole: find-in-page, textContent readers and screen readers still see every row.\n * 'auto' in contain-intrinsic-size keeps a row's last real height once it has rendered; the\n * auto-scroll ResizeObserver re-sticks to the bottom as estimated rows resolve.\n * Containment clips paint at the row's edge, so overflow-clip-margin leaves room for focus rings\n * (2px outline + 2px offset); @supports keeps browsers without it on the old, unclipped path.\n */\n@supports (overflow-clip-margin: 8px) {\n .jini-chat-pane .jini-message {\n content-visibility: auto;\n contain-intrinsic-size: auto 160px;\n overflow-clip-margin: 8px;\n }\n}\n/* A mid-run user bubble is nested inside an assistant turn, so the preceding tool/text block\n * has no message-row bottom margin. Give it the same separation as an ordinary next turn. */\n.jini-chat-pane .jini-message-user--mid-run {\n margin-top: 20px;\n}\n/*\n * Bubble on the content, not the row: MessageRow.tsx renders attachments as siblings of\n * .jini-message-content inside .jini-message-user, each with their own chip styling -- bubbling\n * the whole row would nest one background box inside another. This replaces a dead rule this file\n * shipped with (.jini-message-row[data-role=\"user\"] / .jini-message-row.user): MessageRow.tsx\n * has never rendered a jini-message-row class or a data-role attribute, and a user message's\n * root class is jini-message-user, not bare \"user\" -- the selector never matched anything real.\n */\n.jini-chat-pane .jini-message-user .jini-message-content {\n margin-left: auto;\n padding: 10px 13px;\n width: fit-content;\n max-width: 88%;\n /* User bubbles share this style at every nesting depth, regardless of message length. */\n background: var(--jini-chat-user-bubble, #1a1b1c);\n color: var(--jini-chat-user-bubble-ink, #fff);\n border-radius: 12px 12px 3px 12px;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachments {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n justify-content: flex-end;\n margin-bottom: 4px;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip {\n font: inherit;\n font-size: 11px;\n line-height: 1.4;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n padding: 3px 10px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip:hover {\n color: var(--jini-chat-text);\n border-color: var(--jini-chat-border-strong);\n}\n/* A sent image this browser still holds (SentAttachment.tsx): a small square thumbnail instead of\n * the filename pill. Same click target and preview modal; cover-cropped so mixed aspect ratios line up. */\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip.is-thumbnail {\n width: 64px;\n height: 64px;\n padding: 0;\n overflow: hidden;\n border-radius: 10px;\n}\n.jini-chat-pane .jini-message-user .jini-message-attachment-chip.is-thumbnail:focus-visible {\n outline: 2px solid var(--jini-chat-accent);\n outline-offset: 2px;\n}\n.jini-chat-pane .jini-message-attachment-thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n.jini-chat-pane .jini-message-agent {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: .02em;\n text-transform: uppercase;\n color: var(--jini-chat-faint);\n margin-bottom: 4px;\n}\n.jini-chat-pane .jini-message-error { color: var(--danger); font-size: 13px; }\n.jini-chat-pane .jini-message-pending { color: var(--jini-chat-muted); font-size: 13px; font-style: italic; }\n/*\n * The pending-turn strip: a composer-driven turn typed while a run is already streaming, rendered\n * as the newest entry in the transcript (MessageList.tsx's pendingPrompt prop) rather than a\n * banner bolted above the composer \u2014 matching Claude Code/ChatGPT's own pending-message UX\n * (previously ChatPane.tsx's ChatPaneQueuedPrompt, which rendered between the drop-target's drag\n * announcement and the composer; that component is gone).\n *\n * Owner's own framing was \"why isn't the queue message just put in the chat\" \u2014 so\n * .jini-chat-pane__queued-text below is deliberately NOT a variant banner shape; it copies\n * .jini-message-user .jini-message-content's bubble geometry line for line (width: fit-content,\n * max-width: 88%, same padding/border-radius) so it reads as the operator's own message, not a\n * system notice. What marks it as NOT YET SENT is two properties a real sent bubble never carries:\n * a dashed border (a sent bubble has none at all) and reduced opacity \u2014 chosen over the old boxed\n * strip-plus-label treatment because those two are legible at a glance without adding a second\n * visual language next to the transcript's own. The former \"QUEUED\" chip label is gone for the same\n * reason: once the bubble itself reads as pending, a label restates it. role=\"status\" plus the\n * aria-label on the row (MessageList.tsx) still carry that meaning to assistive tech that can't see\n * the dashed border. The cancel action is a separate, smaller line below the bubble (mirroring\n * .jini-message-actions--user's own placement under a real message) rather than inline inside it,\n * so it reads as secondary to the bubble, not competing with it.\n *\n * Reuses this theme's own tokens rather than the currentColor/color-mix approach reference.css uses\n * for the same rules \u2014 that file is deliberately token-free because it ships unstyled and undocked\n * from any root that defines custom properties, but this stylesheet's .jini-chat-pane root already\n * defines concrete --jini-chat-* values for every other rule here, so matching that (not\n * reference.css's host-agnostic fallback) is the actual local convention.\n * NOTE: this comment avoids backtick quoting on purpose, same as the cancel-button comment below \u2014\n * the whole stylesheet lives inside a JS template literal, so a literal backtick here would\n * terminate the string, not just this comment.\n */\n.jini-chat-pane__queued {\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n gap: 4px;\n margin: 4px 0 0;\n}\n.jini-chat-pane__queued-text {\n width: fit-content;\n max-width: 88%;\n padding: 10px 13px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-subtle);\n border: 1px dashed var(--jini-chat-border);\n border-radius: 12px 12px 3px 12px;\n opacity: .68;\n overflow-wrap: break-word;\n}\n.jini-chat-pane__queued-actions {\n display: flex;\n justify-content: flex-end;\n}\n/*\n * A plain-text cancel affordance below the pending bubble, not a primary action \u2014 without this\n * reset a host's own global 'button' styling (background, border, padding, border-radius, bold\n * text) paints it as a full pill-shaped button that visually competes with (and can overlap) the\n * transcript around it \u2014 the exact failure an operator hit live in one host. '.jini-chat-pane\n * button { font: inherit }' near the top of this file already resets font-family/line-height/etc\n * to this pane's own ambient values; font-size/font-weight are redeclared here (not just left at\n * that inherited ambient size) to size this specific control down to a caption scale, well under\n * the bubble's own 14px text.\n * NOTE: this comment avoids backtick quoting on purpose (see this file's own precedent further\n * down) \u2014 the whole stylesheet lives inside a JS template literal, so a literal backtick here\n * would terminate the string, not just this comment.\n */\n.jini-chat-pane__queued-cancel {\n padding: 2px 6px;\n color: var(--jini-chat-muted);\n background: none;\n border: 0;\n border-radius: 6px;\n font-size: 11px;\n font-weight: 400;\n cursor: pointer;\n}\n.jini-chat-pane__queued-cancel:hover {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-border);\n}\n/*\n * Per-message action row (MessageRow.tsx's 'CopyMessageButton', Icon.tsx's 'copy' glyph): quiet,\n * icon-only, always present rather than a hover-only reveal, so keyboard and touch users get the\n * same access a mouse hover would give. The assistant variant carries a thin left rail (its own\n * left border) to read as attached to the turn above it without repeating that message's own\n * chrome; the user variant skips the rail and instead mirrors its bubble's own right alignment.\n * Copy is the only live button today \u2014 'jini-message-actions--assistant' is an intentional\n * extension seam, not the finished row; see that row's own call-site comment in MessageRow.tsx.\n */\n.jini-chat-pane .jini-message-actions {\n display: flex;\n align-items: center;\n gap: 2px;\n margin-top: 6px;\n}\n.jini-chat-pane .jini-message-actions--assistant {\n padding-left: 8px;\n border-left: 2px solid var(--jini-chat-border-soft);\n}\n.jini-chat-pane .jini-message-actions--user { justify-content: flex-end; }\n.jini-chat-pane .jini-message-action-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n padding: 0;\n color: var(--jini-chat-faint);\n background: none;\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-message-action-btn:hover { color: var(--jini-chat-muted); background: var(--jini-chat-subtle); }\n.jini-chat-pane .jini-message-action-btn:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 1px; }\n/* Visually hidden but present in the DOM (never display:none) so the polite live region beside\n each copy button still gets announced \u2014 same clip-rect technique this package already uses for\n its other visually-hidden-but-functional controls. */\n.jini-chat-pane .jini-message-copy-status {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n.jini-chat-pane__controls {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 4;\n padding: 0 16px 14px;\n background: linear-gradient(to bottom, transparent, var(--jini-chat-bg) 22px);\n}\n.jini-chat-pane__suggestions {\n display: flex;\n gap: 8px;\n margin: 0 2px 8px;\n overflow-x: auto;\n}\n.jini-chat-pane__suggestion {\n flex: 0 0 auto;\n max-width: 290px;\n overflow: hidden;\n padding: 7px 11px;\n color: var(--jini-chat-muted);\n background: color-mix(in srgb, var(--jini-chat-panel) 92%, transparent);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n font-size: 11px;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: pointer;\n}\n.jini-chat-pane__error {\n margin: 0 2px 8px;\n padding: 8px 10px;\n color: #8c2f20;\n background: #fff1ed;\n border: 1px solid #f2c8bb;\n border-radius: 8px;\n font-size: 12px;\n}\n.jini-chat-pane__error-action {\n padding: 0;\n color: inherit;\n background: none;\n border: 0;\n font: inherit;\n font-weight: 650;\n text-decoration: underline;\n cursor: pointer;\n}\n.jini-chat-pane__error-action:hover { opacity: .82; }\n.jini-chat-pane__status {\n margin: 0 2px 8px;\n padding: 8px 10px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n font-size: 12px;\n}\n.jini-chat-pane__drop-target {\n position: relative;\n margin: -8px -8px -8px;\n padding: 6px;\n border: 2px dashed transparent;\n border-radius: 16px;\n transition:\n background-color .14s ease,\n border-color .14s ease,\n box-shadow .14s ease;\n}\n.jini-chat-pane__drop-target.is-dragging-files {\n background: color-mix(in srgb, var(--jini-chat-accent-soft) 72%, transparent);\n border-color: var(--jini-chat-accent);\n box-shadow: 0 0 0 1px color-mix(in srgb, var(--jini-chat-accent) 15%, transparent);\n}\n.jini-chat-pane__drop-target.is-dragging-files .jini-composer {\n background: color-mix(in srgb, var(--jini-chat-accent-soft) 58%, var(--jini-chat-panel));\n border-color: color-mix(in srgb, var(--jini-chat-accent) 68%, var(--jini-chat-border));\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--jini-chat-accent) 10%, transparent);\n}\n.jini-chat-pane__drop-announcement {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n clip-path: inset(50%);\n}\n.jini-chat-pane .jini-composer {\n position: relative;\n overflow: visible;\n padding: 0;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: none;\n transition: border-color .16s ease, box-shadow .16s ease;\n}\n.jini-chat-pane .jini-composer:focus-within {\n background: var(--jini-chat-panel);\n border-color: color-mix(in srgb, var(--jini-chat-accent) 22%, var(--jini-chat-border-strong));\n box-shadow: 0 1px 2px rgba(26, 25, 22, .06), 0 0 0 1px rgba(201, 100, 66, .06);\n}\n/*\n * Pinned-context zone: whatever a host pins above the composer input (selected plugins, MCP\n * servers, or anything a future host adds -- see 'leadingAccessories''s own doc in 'slots.ts').\n * 'Composer.tsx' only mounts this element when 'slots.leadingAccessories' is a truthy prop value,\n * but a host that always supplies a TRAY COMPONENT rather than conditionally supplying the prop\n * itself (one that internally renders 'null' when nothing is pinned) still leaves an empty node\n * behind -- ':empty' below is what actually collapses THAT case to zero height and zero seam,\n * mirroring '.jini-attachment-tray:empty' a few rules down, which solves the identical problem for\n * the real file-attachment tray.\n *\n * Collapsed (nothing pinned): zero height, zero padding, invisible (0-width) seam -- no reserved\n * space, matching the owner's \"if nil, it doesn't animate\" requirement exactly, since there is\n * nothing here to animate.\n *\n * Populated: grows to a single fixed-height row via 'max-height' (not 'height: auto', which can't\n * transition) and gains a hairline seam using the SAME border/token '.jini-composer-footer' below\n * already uses one zone down -- reads as one more zone of the same control, not a new floating\n * box. The transition runs both directions, so removing the last pinned item gets a matching exit,\n * not an instant cut.\n *\n * 'overflow-x: auto' turns this into a single scrollable row instead of the wrap-and-grow a naive\n * flex row would do with six or more items; '> *' keeps any direct host child (a single tray\n * wrapper, a bare row of buttons, anything) from shrinking to fit, so overflow -- not internal\n * wrapping -- is what kicks in once content is wider than the zone.\n *\n * The \"more content\" cue is a real shadow/vignette, not a plain alpha fade -- an alpha fade toward\n * this zone's own background was tried first and rejected on review: the chip and the zone\n * background are already close in luminance, so fading a chip's OWN pixels toward transparent read\n * as \"something is clipped/broken\", not \"swipe for more\", at a glance with no hover. The four-layer\n * 'background' below is the standard scroll-shadow technique instead: a real 'color-mix'-tinted\n * vignette (darkens in a light theme, lightens in a dark one, since it's mixed from the theme's OWN\n * text color rather than a fixed hex) drawn at each edge -- but each 'scroll'-attached shadow layer\n * is covered by a same-edge 'local'-attached solid-to-transparent layer that scrolls WITH the\n * content, so the shadow is only ever visible on the edges that still have more to reveal: at rest\n * (scrolled to the start) the left shadow is hidden and the right one shows; scroll all the way to\n * the true end and the right shadow hides too. Verified live (not just by reading the technique):\n * scrolling this element from 0 to its max scrollLeft measurably fades the right cover's computed\n * background-position across the full track and the shadow disappears exactly at the end.\n */\n.jini-chat-pane .jini-composer-leading {\n display: flex;\n align-items: center;\n overflow-x: auto;\n overflow-y: hidden;\n max-height: 0;\n padding: 0 12px;\n border-bottom: 0 solid var(--jini-chat-border-soft);\n opacity: 0;\n scrollbar-width: thin;\n transition:\n max-height .22s cubic-bezier(.3, .1, .25, 1),\n padding .22s cubic-bezier(.3, .1, .25, 1),\n border-bottom-width .22s ease,\n opacity .16s ease;\n background-color: var(--jini-chat-subtle);\n background-repeat: no-repeat;\n background-attachment: local, local, scroll, scroll;\n background-position: 0 0, 100% 0, 0 0, 100% 0;\n background-size: 26px 100%, 26px 100%, 20px 100%, 20px 100%;\n background-image:\n linear-gradient(to right, var(--jini-chat-subtle), transparent),\n linear-gradient(to left, var(--jini-chat-subtle), transparent),\n linear-gradient(to right, color-mix(in srgb, var(--jini-chat-text) 30%, transparent), transparent),\n linear-gradient(to left, color-mix(in srgb, var(--jini-chat-text) 30%, transparent), transparent);\n}\n.jini-chat-pane .jini-composer-leading:not(:empty) {\n max-height: 64px;\n padding: 10px 12px;\n border-bottom-width: 1px;\n opacity: 1;\n}\n.jini-chat-pane .jini-composer-leading > * {\n flex: none;\n min-width: max-content;\n}\n@media (prefers-reduced-motion: reduce) {\n .jini-chat-pane .jini-composer-leading { transition: none; }\n}\n.jini-chat-pane .jini-attachment-tray {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n max-height: 88px;\n overflow-y: auto;\n padding: 8px 10px 0;\n scrollbar-width: thin;\n}\n.jini-chat-pane .jini-attachment-tray:empty { display: none; }\n/*\n * The sanctioned reuse path (see '.jini-composer-leading''s own doc above and 'AttachmentTray''s\n * module doc): a host nesting Jini's own tray/chip classes inside the pinned-context zone to match\n * the real attachment tray's look. Un-does the wrap-and-grow rule directly above -- meant for that\n * tray's OWN use one zone down, where wrapping onto a second line is fine -- so the OUTER zone's\n * single scroll axis is the one that wins here instead. The real file-attachment tray is\n * unaffected: it always renders as this zone's sibling ('Composer.tsx'), never its descendant.\n */\n.jini-chat-pane .jini-composer-leading .jini-attachment-tray {\n flex-wrap: nowrap;\n max-height: none;\n overflow: visible;\n padding: 0;\n}\n/* Half the tray's width, so a sidebar-width pane fits two chips per row and its 88px cap shows two\n rows (four chips) before scrolling; at full width each chip took a row and the third was hidden. */\n.jini-chat-pane .jini-attachment-chip {\n display: inline-flex;\n align-items: center;\n box-sizing: border-box;\n min-width: 0;\n max-width: min(calc(50% - 3px), 290px);\n height: 36px;\n padding: 3px 4px 3px 5px;\n color: var(--jini-chat-text);\n background: color-mix(in srgb, var(--jini-chat-panel) 88%, var(--jini-chat-subtle));\n border: 1px solid var(--jini-chat-border);\n border-radius: 9px;\n box-shadow: 0 1px 1px rgba(13, 12, 10, .025);\n animation: jini-chat-chip-in .16s ease both;\n}\n.jini-chat-pane .jini-composer-leading .jini-attachment-chip { max-width: min(100%, 290px); }\n@keyframes jini-chat-chip-in {\n from { opacity: 0; transform: translateY(3px) scale(.94); }\n to { opacity: 1; transform: none; }\n}\n@media (prefers-reduced-motion: reduce) {\n .jini-chat-pane .jini-attachment-chip { animation: none; }\n}\n.jini-chat-pane .jini-attachment-chip-body {\n display: flex;\n align-items: center;\n min-width: 0;\n gap: 7px;\n /* A button (opens the preview modal), reset to the chip's own look. */\n padding: 0;\n font: inherit;\n color: inherit;\n text-align: left;\n background: none;\n border: 0;\n border-radius: 7px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-attachment-chip-body:focus-visible {\n outline: 2px solid var(--jini-chat-accent);\n outline-offset: 2px;\n}\n/* A staged image this browser holds: the thumbnail takes the icon tile's place, same size. */\n.jini-chat-pane .jini-attachment-chip-thumb {\n display: block;\n flex: 0 0 auto;\n width: 26px;\n height: 26px;\n object-fit: cover;\n border-radius: 7px;\n background: var(--jini-chat-subtle);\n}\n.jini-chat-pane .jini-attachment-chip-icon {\n display: grid;\n flex: 0 0 auto;\n place-items: center;\n width: 26px;\n height: 26px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border-radius: 7px;\n}\n.jini-chat-pane .jini-attachment-chip-icon.is-image {\n color: var(--jini-chat-accent);\n background: var(--jini-chat-accent-soft);\n}\n.jini-chat-pane .jini-attachment-chip-copy {\n display: flex;\n min-width: 0;\n flex-direction: column;\n line-height: 1.15;\n}\n.jini-chat-pane .jini-attachment-chip-name {\n overflow: hidden;\n font-size: 11.5px;\n font-weight: 550;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-attachment-chip-size {\n color: var(--jini-chat-muted);\n font-size: 9.5px;\n}\n.jini-chat-pane .jini-attachment-remove {\n display: grid;\n flex: 0 0 auto;\n place-items: center;\n width: 25px;\n height: 25px;\n margin-left: 4px;\n padding: 0;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 0;\n border-radius: 7px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-attachment-remove:hover,\n.jini-chat-pane .jini-attachment-remove:focus-visible {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n outline: none;\n}\n/* Phones: the 25px \"x\" gets an invisible 44px tap area; the chip keeps its size. The area ends at the\n chip's bottom edge and grows up into the tray's 8px top padding / row gap, because growing down\n past the last row would make the 88px tray scroll by a few pixels. */\n@media (max-width: 640px), (pointer: coarse) {\n .jini-chat-pane .jini-attachment-remove { position: relative; }\n .jini-chat-pane .jini-attachment-remove::before {\n content: '';\n position: absolute;\n bottom: -5.5px;\n left: 50%;\n width: 44px;\n height: 44px;\n transform: translateX(-50%);\n }\n}\n.jini-chat-pane .jini-composer-input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n min-height: 92px;\n resize: none;\n padding: 12px 14px 8px;\n color: var(--jini-chat-text);\n background: transparent;\n border: 0;\n outline: 0;\n font-size: 13.5px;\n line-height: 1.6;\n}\n/* Shared focus-zoom floor: embeds at every width, all panes on small/touch viewports. */\n.jini-chat-embed .jini-chat-pane .jini-composer-input { font-size: 16px; }\n@media (max-width: 640px), (pointer: coarse) {\n .jini-chat-pane.jini-chat-pane .jini-composer-input { font-size: 16px; }\n}\n.jini-chat-pane .jini-composer-input::placeholder { color: var(--jini-chat-faint); }\n.jini-chat-pane .jini-composer-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n min-height: 42px;\n gap: 6px;\n padding: 6px 8px 8px;\n border-top: 1px solid var(--jini-chat-border-soft);\n}\n.jini-chat-pane .jini-composer-attachment-picker { display: inline-flex; }\n/* Host controls slotted at the start of the footer row, right after the attach/discovery \"+\"\n trigger above -- see ComposerSlots.footerLeadingAccessory's own doc (slots.ts) for why this\n exists as a third footer slot alongside footerAccessories/plusMenuItems. */\n.jini-chat-pane .jini-composer-footer-leading { display: inline-flex; align-items: center; }\n/* NOT 'position: relative' \u2014 the discovery popover (below) anchors off '.jini-composer' itself\n (the nearest positioned ancestor once this wrapper opts out), not off this small trigger-button\n wrapper. Anchoring to the wrapper put the popover's bottom edge at the wrapper's own top edge \u2014\n inside the footer, just below the textarea \u2014 so any popover taller than a couple of rows grew\n upward straight over the textarea and blocked clicks into it (reproduced live: Playwright's own\n click on the textarea failed with \"intercepts pointer events\" while the popover was open). */\n.jini-chat-pane .jini-composer-discovery { display: inline-flex; }\n.jini-chat-pane .jini-composer-discovery-menu,\n.jini-chat-pane .jini-composer-slash-menu {\n position: absolute;\n z-index: 8;\n display: flex;\n flex-direction: column;\n gap: 4px;\n max-height: 280px;\n overflow-y: auto;\n padding: 8px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 10px;\n box-shadow: 0 12px 30px rgb(0 0 0 / 14%);\n}\n/* Both anchor off '.jini-composer' ('bottom: calc(100% + 6px)' = fully above the textarea AND the\n footer, not just above the trigger button) so the popover floats as a clean detached card instead\n of overlapping the input it sits next to. */\n.jini-chat-pane .jini-composer-discovery-menu {\n inset-inline-start: 8px;\n bottom: calc(100% + 6px);\n width: min(280px, calc(100vw - 32px));\n}\n.jini-chat-pane .jini-composer-slash-menu {\n inset-inline: 8px;\n bottom: calc(100% + 6px);\n}\n.jini-chat-pane .jini-composer-discovery-group { display: flex; flex-direction: column; gap: 2px; }\n.jini-chat-pane .jini-composer-discovery-group-label {\n padding: 6px 8px 2px;\n color: var(--jini-chat-faint);\n font-size: 10px;\n font-weight: 700;\n letter-spacing: .08em;\n text-transform: uppercase;\n}\n.jini-chat-pane .jini-composer-discovery-item {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 2px;\n width: 100%;\n padding: 7px 8px;\n color: inherit;\n text-align: start;\n background: transparent;\n border: 0;\n border-radius: 7px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-composer-discovery-item:hover {\n background: var(--jini-chat-panel);\n}\n.jini-chat-pane .jini-composer-discovery-item.is-active {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-accent-soft);\n outline: 1px solid var(--jini-chat-accent);\n outline-offset: -1px;\n box-shadow: inset 3px 0 0 var(--jini-chat-accent);\n}\n.jini-chat-pane .jini-composer-discovery-item small { color: var(--jini-chat-muted); }\n/* The full description used to print under every row unconditionally, which is why the palette\n grew to dozens of multi-line rows on a bare \"/\" \u2014 kept in the accessibility tree at rest (never\n 'display: none', which most screen readers drop from that tree) so 'aria-describedby' keeps\n resolving whether or not anything is hovering.\n FIXED (was: opacity-only toggle, THEN an absolutely-positioned overlay) \u2014 two owner-reported\n bugs in sequence on the same element:\n 1. 'opacity: 0' alone hides a box visually but does not remove it from layout, so an\n absolutely-positioned tooltip sized to its full natural content height (routinely 50-260px\n for a real description) still counted toward its scrolling ancestor's scrollable overflow\n region while invisible ('scrollHeight' measured 501px against a 242px 'clientHeight' for a\n 6-row list), leaving a blank scrollable gap after the last real row. Fixed by collapsing the\n box itself at rest ('max-height: 0; overflow: hidden;'), not just hiding it \u2014 carried\n forward below.\n 2. That fix still left this 'position: absolute; top: 100%' of its own row (formerly anchored\n via 'position: relative' on '.jini-composer-discovery-item' above, now removed \u2014 nothing\n anchors off it anymore): 'z-index' only controls paint order, not which box a box is\n confined to, so on hover/focus the expanded tooltip painted directly over whichever row\n happened to sit below it in the SAME scrollable list (owner report: hovering \"UI/UX Design\n (Skill)\" covered the \"/mcp\" entry beneath it \u2014 not a stacking-order problem, a containment\n one). Fixed by dropping 'position: absolute' entirely: this is now a normal in-flow child of\n the row's own 'flex-direction: column' box, still collapsed to negligible size at rest via\n 'max-height: 0; overflow: hidden;', but on hover/focus it grows the ROW's own height instead\n of floating a detached box over the next row \u2014 the popover's flex column (and every row\n below) gets pushed down to make room, so it can never occlude a sibling item. This also\n retires the panel-styled \"floating card\" look (background/border/shadow): an in-flow caption\n reads correctly with the row's own muted small-text convention above, and dropping it removes\n the last reason this needed its own stacking context. 'pointer-events: none' is kept for\n parity even though nothing sits under it to protect anymore. */\n.jini-chat-pane .jini-composer-discovery-description {\n width: 100%;\n max-height: 0;\n padding: 0;\n overflow: hidden;\n color: var(--jini-chat-muted);\n font-size: 12px;\n font-weight: 400;\n line-height: 1.4;\n white-space: normal;\n opacity: 0;\n pointer-events: none;\n transition: opacity .12s ease;\n}\n.jini-chat-pane .jini-composer-discovery-item:hover .jini-composer-discovery-description,\n.jini-chat-pane .jini-composer-discovery-item:focus-visible .jini-composer-discovery-description {\n max-height: none;\n padding-top: 2px;\n overflow: visible;\n opacity: 1;\n}\n/* '<code>'/'<small>' both default to the browser's UA styling (monospace at an unrelated size)\n with nothing here overriding it \u2014 computed font-SIZE happened to already match the row label\n (13.5px), so the visible mismatch is font-FAMILY: raw monospace next to the label's sans-serif\n stack reads as a different, larger type scale even at equal size. Inheriting the row's own font\n puts both on one consistent scale. */\n.jini-chat-pane .jini-composer-slash-argument,\n.jini-chat-pane .jini-composer-slash-confirm-badge {\n font-family: inherit;\n font-size: inherit;\n font-weight: 400;\n color: var(--jini-chat-muted);\n}\n.jini-chat-pane .jini-composer-file-input {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n clip-path: inset(50%);\n}\n.jini-chat-pane .jini-composer-attach {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n padding: 0;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 1px solid transparent;\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-composer-attach:hover:not(:disabled) {\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border-color: var(--jini-chat-border);\n}\n.jini-chat-pane .jini-composer-attach:disabled { opacity: .45; cursor: default; }\n/* The working-directory trigger button reuses '.jini-composer-attach' itself (Composer.tsx) rather\n than a class duplicated here, so it is byte-identical in size/hit-area/hover/focus to its\n neighbour rather than an approximation that could drift out of sync. Only the wrapper and its\n popover are new. NOT 'position: relative' on the wrapper, for the same reason noted above\n '.jini-composer-discovery': the popover's un-measured first paint (before the layout effect sets\n its 'position: fixed' inline style) falls back to this rule's 'position: absolute', which needs\n '.jini-composer' itself \u2014 the nearest positioned ancestor \u2014 not this wrapper, to anchor against. */\n.jini-chat-pane .jini-composer-workdir { display: inline-flex; }\n.jini-chat-pane .jini-composer-workdir-panel {\n position: absolute;\n z-index: 8;\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px;\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 10px;\n box-shadow: 0 12px 30px rgb(0 0 0 / 14%);\n}\n.jini-chat-pane .jini-composer-workdir-input {\n flex: 1;\n min-width: 160px;\n padding: 6px 8px;\n color: var(--jini-chat-text);\n font: inherit;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 6px;\n}\n.jini-chat-pane .jini-composer-workdir-confirm {\n padding: 6px 10px;\n color: white;\n white-space: nowrap;\n background: var(--jini-chat-text-strong);\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n/* `.jini-icon-spin` is `<Icon name=\"spinner\">` (the tool-row \"Running\" badge). Its keyframes used to\n live only in reference.css, which no host imports, so the badge sat still. */\n.jini-composer-spinner,\n.jini-runtime-spinner,\n.jini-icon-spin { animation: jini-chat-spin .8s linear infinite; }\n@keyframes jini-chat-spin { to { transform: rotate(360deg); } }\n.jini-chat-pane .jini-composer-footer-accessories {\n display: flex;\n margin-left: auto;\n min-width: 0;\n}\n.jini-chat-pane .jini-composer-send-group {\n display: flex;\n flex-shrink: 0;\n gap: 6px;\n}\n/* A footer with no attach/accessory controls before the send group (a public visitor chat) would\n leave the lone send button at the start under the footer's space-between; keep it at the end. */\n.jini-chat-pane .jini-composer-send-group:first-child { margin-left: auto; }\n.jini-chat-pane .jini-composer-send {\n display: grid;\n place-items: center;\n width: 32px;\n height: 32px;\n padding: 0;\n color: white;\n background: var(--jini-chat-text-strong);\n border: 0;\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-composer-send:disabled {\n color: #aaa59e;\n background: #ece8e2;\n cursor: default;\n}\n.jini-chat-pane__workdir {\n display: flex;\n align-items: center;\n gap: 7px;\n min-width: 0;\n padding: 5px 4px 0;\n color: var(--jini-chat-muted);\n font-size: 13px;\n}\n.jini-chat-pane__workdir code {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-picker {\n position: relative;\n display: inline-flex;\n align-items: flex-start;\n}\n.jini-chat-pane .jini-working-dir-trigger-row {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n.jini-chat-pane .jini-working-dir-trigger {\n appearance: none;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 4px 6px;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 0;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-working-dir-trigger:hover,\n.jini-chat-pane .jini-working-dir-trigger:focus-visible {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n outline: none;\n}\n.jini-chat-pane .jini-working-dir-trigger.invalid { color: var(--danger); }\n.jini-chat-pane .jini-working-dir-trigger-icon { flex: 0 0 auto; opacity: .75; }\n.jini-chat-pane .jini-working-dir-trigger.invalid .jini-working-dir-trigger-icon {\n color: var(--danger);\n opacity: 1;\n}\n.jini-chat-pane .jini-working-dir-trigger-label {\n max-width: 240px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-trigger-chevron { flex: 0 0 auto; opacity: .55; }\n.jini-chat-pane .jini-working-dir-panel,\n.jini-chat-pane .jini-working-dir-flyout {\n position: absolute;\n z-index: 90;\n display: flex;\n flex-direction: column;\n min-width: 210px;\n padding: 5px;\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border-strong);\n border-radius: 12px;\n box-shadow: 0 1px 2px rgba(0, 0, 0, .04), 0 10px 28px -10px rgba(0, 0, 0, .18);\n}\n.jini-chat-pane .jini-working-dir-panel {\n top: calc(100% + 6px);\n left: 0;\n}\n.jini-chat-pane .jini-working-dir-panel.up {\n top: auto;\n bottom: calc(100% + 6px);\n}\n.jini-chat-pane .jini-working-dir-item {\n appearance: none;\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n min-height: 32px;\n padding: 0 9px;\n color: var(--jini-chat-text-strong);\n background: transparent;\n border: 0;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n text-align: left;\n cursor: pointer;\n}\n.jini-chat-pane .jini-working-dir-item:hover,\n.jini-chat-pane .jini-working-dir-item:focus-visible,\n.jini-chat-pane .jini-working-dir-recent-item:hover,\n.jini-chat-pane .jini-working-dir-recent-item:focus-visible {\n background: var(--jini-chat-subtle);\n outline: none;\n}\n.jini-chat-pane .jini-working-dir-item-icon { flex: 0 0 auto; opacity: .8; }\n.jini-chat-pane .jini-working-dir-item > span {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-item-chevron { margin-left: auto; opacity: .55; }\n.jini-chat-pane .jini-working-dir-submenu-row { position: relative; }\n.jini-chat-pane .jini-working-dir-flyout {\n top: -5px;\n left: 100%;\n z-index: 91;\n min-width: 220px;\n max-width: 320px;\n margin-left: 4px;\n}\n.jini-chat-pane .jini-working-dir-flyout.up { top: auto; bottom: -5px; }\n.jini-chat-pane .jini-working-dir-flyout::before {\n position: absolute;\n top: 0;\n left: -8px;\n width: 8px;\n height: 100%;\n content: \"\";\n}\n.jini-chat-pane .jini-working-dir-recent-item {\n appearance: none;\n display: grid;\n grid-template-rows: auto auto;\n grid-template-columns: auto 1fr;\n align-items: center;\n width: 100%;\n min-height: 36px;\n padding: 4px 9px;\n color: var(--jini-chat-text-strong);\n background: transparent;\n border: 0;\n border-radius: 8px;\n font: inherit;\n text-align: left;\n cursor: pointer;\n column-gap: 9px;\n}\n.jini-chat-pane .jini-working-dir-recent-item .jini-working-dir-item-icon {\n grid-row: 1 / span 2;\n}\n.jini-chat-pane .jini-working-dir-recent-name,\n.jini-chat-pane .jini-working-dir-recent-path {\n grid-column: 2;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-chat-pane .jini-working-dir-recent-name { font-size: 13px; }\n.jini-chat-pane .jini-working-dir-recent-path {\n color: var(--jini-chat-muted);\n font-size: 11px;\n opacity: .7;\n}\n.jini-chat-pane .jini-working-dir-empty {\n padding: 8px 10px;\n color: var(--jini-chat-muted);\n font-size: 12px;\n white-space: nowrap;\n opacity: .7;\n}\n.jini-runtime-picker { position: relative; }\n.jini-runtime-trigger {\n display: inline-grid;\n grid-template-columns: 24px 14px;\n align-items: center;\n gap: 7px;\n height: 32px;\n min-width: 0;\n padding: 3px 7px 3px 5px;\n color: var(--jini-chat-text);\n background: transparent;\n border: 0;\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-runtime-trigger:hover { background: var(--jini-chat-subtle); }\n.jini-runtime-trigger__copy { display: none; }\n.jini-runtime-trigger__copy strong {\n overflow: hidden;\n max-width: 122px;\n font-size: 11px;\n font-weight: 650;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-runtime-trigger__copy small {\n overflow: hidden;\n max-width: 122px;\n color: var(--jini-chat-muted);\n font-size: 9px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-runtime-trigger__chevron { color: var(--jini-chat-muted); font-size: 12px; }\n.jini-runtime-agent-icon.agent-icon { flex: 0 0 auto; object-fit: contain; }\n.jini-runtime-agent-icon.agent-icon-mono {\n display: inline-block;\n background-color: currentColor;\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-position: center;\n mask-position: center;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n.jini-runtime-agent-icon.agent-icon-fallback {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: #74716b;\n background: #f4f5f7;\n border: 1px solid #e1e5eb;\n border-radius: 6px;\n font-weight: 700;\n}\n.jini-runtime-popover {\n --jini-chat-panel: #fdfcfa;\n --jini-chat-text: #1a1916;\n --jini-chat-text-strong: #0d0c0a;\n --jini-chat-muted: #74716b;\n --jini-chat-faint: #b3b0a8;\n --jini-chat-border: #e1e5eb;\n --jini-chat-border-soft: #edf0f4;\n --jini-chat-subtle: #f4f5f7;\n box-sizing: border-box;\n padding: 8px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: 0 18px 46px rgba(13, 12, 10, .15), 0 2px 8px rgba(13, 12, 10, .06);\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Microsoft YaHei UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n font-size: 13.5px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n}\n.jini-runtime-popover__head {\n display: flex;\n flex-direction: column;\n gap: 3px;\n padding: 10px 12px 12px;\n border-bottom: 1px solid var(--jini-chat-border-soft);\n}\n.jini-runtime-popover__head strong { color: var(--jini-chat-text-strong); font-size: 14px; font-weight: 650; }\n.jini-runtime-popover__head span { color: var(--jini-chat-muted); font-size: 12px; }\n.jini-runtime-mode,\n.jini-runtime-agent,\n.jini-runtime-rescan {\n display: flex;\n align-items: center;\n gap: 11px;\n box-sizing: border-box;\n width: 100%;\n min-height: 40px;\n padding: 8px 11px;\n color: var(--jini-chat-text);\n background: transparent;\n border: 0;\n border-radius: 8px;\n text-align: left;\n cursor: pointer;\n}\n.jini-runtime-mode:hover,\n.jini-runtime-agent:hover:not(:disabled),\n.jini-runtime-rescan:hover:not(:disabled) { background: var(--jini-chat-subtle); }\n.jini-runtime-mode.is-active { font-weight: 650; }\n.jini-runtime-mode:disabled,\n.jini-runtime-agent:disabled,\n.jini-runtime-rescan:disabled { color: var(--jini-chat-faint); cursor: default; }\n.jini-runtime-mode__meta,\n.jini-runtime-agent__status {\n margin-left: auto;\n color: var(--jini-chat-muted);\n font-size: 12px;\n white-space: nowrap;\n}\n.jini-runtime-check { flex: 0 0 auto; color: var(--jini-chat-text); }\n/* Matches .jini-message-attachment-chip/.jini-md-table-expand's pill chrome (same\n background/border/radius formula) so this reads as the same badge language already used\n elsewhere in the pane, not a new one. Sits between the agent name and its status column \u2014\n flex: 0 0 auto keeps it from stretching or shrinking when the row is tight. */\n.jini-runtime-agent__badge {\n flex: 0 0 auto;\n padding: 2px 8px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n font-size: 10px;\n font-weight: 500;\n white-space: nowrap;\n}\n.jini-runtime-section-label {\n padding: 12px 11px 5px;\n color: var(--jini-chat-faint);\n font-size: 11px;\n font-weight: 650;\n letter-spacing: .08em;\n text-transform: uppercase;\n}\n.jini-runtime-agent.is-active { background: transparent; }\n.jini-runtime-agent__copy {\n display: flex;\n flex: 1;\n flex-direction: column;\n min-width: 0;\n}\n.jini-runtime-agent__copy strong { font-size: 13.5px; font-weight: 500; }\n.jini-runtime-agent__copy small {\n overflow: hidden;\n color: #8b857e;\n font-size: 10px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.jini-runtime-empty { padding: 10px 11px; color: var(--jini-chat-muted); font-size: 12px; }\n.jini-runtime-models {\n display: flex;\n flex-direction: column;\n gap: 9px;\n margin-top: 5px;\n padding: 10px 11px 8px;\n border-top: 1px dashed var(--jini-chat-border);\n}\n.jini-runtime-select {\n display: flex;\n flex-direction: column;\n gap: 5px;\n color: var(--jini-chat-muted);\n font-size: 13px;\n}\n.jini-runtime-select select {\n box-sizing: border-box;\n width: 100%;\n min-height: 40px;\n padding: 7px 10px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n}\n/*\n * The BYOK model, rendered as a value rather than a control \u2014 see RuntimeByokDetails for why it\n * is not a disabled select. It occupies the same box the sibling selects do so the row still\n * reads as part of the same list, but carries no border: a bordered box with no affordance is\n * exactly the \"looks editable, isn't\" shape the plain text exists to avoid.\n */\n/*\n * The BYOK model list, which CustomSelect portals to document.body. runtimePopoverPosition puts\n * the runtime popover at z-index 1000; the shared .jini-select-menu default is 60, which is right\n * everywhere else and puts this menu behind the very popover that opened it. One above the\n * popover, not far above: it must clear its own surface and nothing else.\n */\n.jini-select-menu.jini-runtime-model-menu {\n z-index: 1001;\n}\n.jini-runtime-byok-model strong {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n min-height: 40px;\n padding: 7px 10px;\n color: var(--jini-chat-text);\n font-weight: 600;\n word-break: break-all;\n}\n/*\n * Tool-call cards (ToolCard.tsx) and the per-turn usage summary (MessageRow.tsx) ship no CSS of\n * their own \u2014 see this file's own header note on why the package supplies a default theme at all.\n * Collapsed by default (.op-card-head is the only always-visible part), so a run with a dozen tool\n * calls reads as a dozen one-line rows, not a page of raw JSON.\n */\n.jini-chat-pane .jini-message-tools {\n display: flex;\n flex-direction: column;\n gap: 6px;\n width: 100%;\n}\n.jini-chat-pane .op-card {\n border: 1px solid var(--jini-chat-border);\n border-radius: var(--radius);\n background: var(--jini-chat-subtle);\n overflow: hidden;\n font-size: 13px;\n}\n.jini-chat-pane .op-card-head {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 7px 10px;\n background: none;\n border: 0;\n font: inherit;\n text-align: left;\n cursor: pointer;\n color: inherit;\n}\n.jini-chat-pane .op-status { display: inline-flex; flex-shrink: 0; }\n.jini-chat-pane .op-status-ok { color: #4e875f; }\n.jini-chat-pane .op-status-error { color: var(--danger); }\n.jini-chat-pane .op-status-running { color: var(--jini-chat-accent); }\n.jini-chat-pane .op-title { font-weight: 600; flex-shrink: 0; }\n.jini-chat-pane .shimmer-text { animation: jini-chat-shimmer 1.6s ease-in-out infinite; }\n@keyframes jini-chat-shimmer { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }\n@media (prefers-reduced-motion: reduce) { .jini-chat-pane .shimmer-text { animation: none; opacity: .6; } }\n.jini-chat-pane .op-meta {\n color: var(--jini-chat-muted);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: 1;\n min-width: 0;\n}\n.jini-chat-pane .op-expand-chev { flex-shrink: 0; color: var(--jini-chat-faint); }\n/* Collapsed by default: 0 max-height clips the inner content, no display:none \u2014 keeps the height\n transition instead of an instant jump cut. */\n.jini-chat-pane .accordion-collapsible { max-height: 0; overflow: hidden; transition: max-height .15s ease; }\n.jini-chat-pane .accordion-collapsible.open { max-height: 480px; overflow-y: auto; }\n.jini-chat-pane .accordion-collapsible-inner { padding: 0 10px 10px; }\n.jini-chat-pane .op-card-detail { display: flex; flex-direction: column; gap: 6px; }\n.jini-chat-pane .op-path,\n.jini-chat-pane .op-command,\n.jini-chat-pane .op-output {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 6px;\n padding: 6px 8px;\n margin: 0;\n white-space: pre-wrap;\n word-break: break-word;\n}\n/* The \"Waiting for the result or answer\u2026\" / \"Finished with no output.\" line in an expanded tool row (ToolCard.tsx's ToolCallDetail). */\n.jini-chat-pane .op-detail-note { margin: 0; font-size: 12px; color: var(--jini-chat-muted); }\n.jini-chat-pane .op-open {\n border: 1px solid var(--jini-chat-border-strong);\n border-radius: 6px;\n background: var(--jini-chat-panel);\n padding: 2px 8px;\n font-size: 11px;\n cursor: pointer;\n}\n/* The \"Done \u00B7 6m 29s \u00B7 2612 out \u00B7 $0.4028\" line \u2014 real numbers from the run's own kind:'usage' event. */\n.jini-chat-pane .jini-message-usage {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n color: var(--jini-chat-muted);\n margin-top: 2px;\n}\n.jini-chat-pane .jini-message-usage-dot { font-size: 8px; color: #4e875f; }\n/* toolCallDisplay 'collapsed' (ToolCallGroup.tsx): one quiet line per turn, cards open under it. */\n.jini-chat-pane .jini-tool-group { display: flex; flex-direction: column; gap: 6px; width: 100%; }\n.jini-chat-pane .jini-tool-group__toggle {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n align-self: flex-start;\n max-width: 100%;\n padding: 2px 0;\n background: none;\n border: 0;\n font: inherit;\n font-size: 12px;\n color: var(--jini-chat-muted);\n cursor: pointer;\n}\n.jini-chat-pane .jini-tool-group__toggle:hover { color: inherit; }\n.jini-chat-pane .jini-tool-group__toggle:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 2px; border-radius: 4px; }\n.jini-chat-pane .jini-tool-group__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.jini-chat-pane .jini-tool-group[data-failed=\"true\"] .jini-tool-group__toggle { color: var(--danger); }\n/* showThinking (MessageRow.tsx's ThinkingBlock): the model's reasoning, folded, set apart from the answer. */\n.jini-chat-pane .jini-message-thinking { font-size: 12px; color: var(--jini-chat-muted); }\n.jini-chat-pane .jini-message-thinking > summary { cursor: pointer; width: fit-content; }\n.jini-chat-pane .jini-message-thinking__text {\n margin-top: 4px;\n padding-left: 10px;\n border-left: 2px solid var(--jini-chat-border);\n white-space: pre-wrap;\n word-break: break-word;\n}\n/*\n * Fenced code block ('Markdown.tsx''s 'renderBlock' case 'code') and inline 'code' span visual\n * treatment \u2014 previously entirely unstyled (bare UA-default '<pre><code>', no background, border,\n * or padding; a downstream admin host's own stylesheet already flagged this gap in a note next to\n * its unrelated horizontal-scroll fix for the same element). Lives HERE, in the package's own\n * injected default theme, rather than in a host's stylesheet: a host CAN already remap the exact\n * '--jini-chat-*' custom properties this rule reads (one such host's '.admin-chat-dock .jini-chat-\n * pane' block already does, for every other rule in this file), so putting the fix here makes every\n * Jini host look correct by default AND lets a host match its own palette for free, with no new\n * host-side override rule needed at all \u2014 the reverse (host-only CSS) would leave every other\n * embedder of '@jini-ai/chat' with the same unstyled '<pre>' this fix exists to correct.\n * 'max-width'/'overflow-x' here duplicate (harmlessly) a narrower fix that same host's own\n * '.jini-message-content pre' rule already carries for its own fixed-width dock specifically \u2014 this\n * package cannot assume every host has that rule, so a real host-agnostic default belongs here too.\n * NOTE: this comment avoids backtick quoting on purpose \u2014 this whole stylesheet lives inside a JS\n * template literal, so a literal backtick here would terminate the string, not just this comment.\n */\n.jini-chat-pane .jini-message-content pre {\n margin: 6px 0;\n max-width: 100%;\n overflow-x: auto;\n padding: 10px 12px;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12.5px;\n line-height: 1.5;\n}\n.jini-chat-pane .jini-message-content pre code {\n padding: 0;\n background: none;\n border: 0;\n color: inherit;\n font: inherit;\n}\n.jini-chat-pane .jini-message-content :not(pre) > code {\n padding: .15em .4em;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-radius: 4px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: .9em;\n}\n/*\n * Copy affordance for a fenced code block ('Markdown.tsx''s 'CodeBlock'/'CopyCodeButton'). The\n * button lives in its own toolbar row fused to the TOP of the '<pre>' below, not overlaid on top\n * of the code itself \u2014 an overlay would sit over whatever text happens to render at that corner,\n * and '<pre>' already carries its own horizontal scrollbar along its own bottom edge, so a row\n * above it can never collide with that scrollbar either. '.jini-md-code-block' takes over the\n * '<pre>' rule's own top/bottom margin above so the two don't stack, and the toolbar/pre pair\n * share one rounded box (toolbar keeps the top corners, '<pre>' below loses them) instead of two\n * separately-bordered boxes stacked on each other.\n */\n.jini-chat-pane .jini-message-content .jini-md-code-block {\n margin: 6px 0;\n}\n.jini-chat-pane .jini-message-content .jini-md-code-block pre {\n margin: 0;\n border-top: 0;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.jini-chat-pane .jini-md-code-toolbar {\n display: flex;\n justify-content: flex-end;\n padding: 3px;\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border);\n border-bottom: 0;\n border-radius: 8px 8px 0 0;\n}\n.jini-chat-pane .jini-md-code-copy {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 22px;\n padding: 0;\n color: var(--jini-chat-faint);\n background: none;\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n.jini-chat-pane .jini-md-code-copy:hover { color: var(--jini-chat-muted); background: var(--jini-chat-border); }\n.jini-chat-pane .jini-md-code-copy:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 1px; }\n/* Visually hidden but present in the DOM (never display:none) so this polite live region still\n gets announced \u2014 same clip-rect technique 'jini-message-copy-status' above already uses. */\n.jini-chat-pane .jini-md-code-copy-status {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n/*\n * GFM pipe tables ('Markdown.tsx''s 'TableBlock') \u2014 same reasoning as the code-block rules directly\n * above for living in the package's own default theme rather than a host stylesheet. '.jini-md-\n * table-wrap' gets its own horizontal scrollbar so a wide table scrolls internally instead of\n * dragging '.jini-message-list' sideways with it (the exact failure mode that same downstream host's\n * stylesheet documents for the code-block case \u2014 the same fix shape applies here). The \"Expand\n * table\" button only renders ('TableBlock''s own logic) once the table has genuinely overflowed\n * that wrap.\n */\n.jini-md-table-wrap {\n max-width: 100%;\n overflow-x: auto;\n}\n.jini-md-table {\n border-collapse: collapse;\n font-size: 12.5px;\n}\n/*\n * 'min-width' here, not on '.jini-md-table' itself, and picked per column rather than as one flat\n * table-wide number -- a flat table minimum sized for a typical 2-column table would leave a\n * 5-column table crushed just as badly as before (same failure this comment's neighbor above\n * describes), while one sized for 5 columns would force pointless overflow on a simple 2-column\n * table. Per-cell 'min-width' scales the effective table floor with the real column count instead.\n * 130px was picked empirically, not guessed: measured (real Chromium, not jsdom -- 'scrollWidth'/\n * 'clientWidth' are unimplemented there) against this package's narrowest known real host, an\n * admin chat dock fixed at 380px wide ('admin-chat-dock' in that host's own stylesheet), which\n * nets out to roughly 238px of usable width once that dock's own message-list padding, the 80%\n * message-bubble cap, and this content's own padding are all applied. Below 120px per column the\n * table still fits inside those 238px and never overflows -- the exact bug this fix exists to\n * correct. 130px was chosen just above that measured 120px crossover (a small margin so it clears\n * reliably rather than sitting exactly on the boundary) and reads as a genuinely defensible per-\n * column floor on its own terms too: with this rule's own 6px/10px cell padding subtracted, it\n * leaves about 110px of text room, roughly 14-16 characters per wrapped line at this rule's 12.5px\n * font -- enough that a table cell wraps in short phrases instead of one word per line, which is\n * the readability floor this fix is actually chasing. One real consequence, verified rather than\n * hand-waved: at this width, ANY 2-column table in that narrow dock now overflows and gets the\n * \"Expand table\" affordance, including ones with short cell content -- there is no per-cell\n * min-width value that can overflow a long-prose 2-column table without also overflowing a short\n * one at the same column count, because the browser's own auto-layout table-width algorithm sizes\n * the table to the greater of its container's width and the sum of each column's own minimum --\n * content length past that minimum only changes how much a column wraps internally, never whether\n * the table exceeds its wrap. Accepted here: in a surface already this narrow, showing \"Expand\n * table\" a little more eagerly is the better failure mode than silently crushing a table's own\n * content illegibly, which is the state this whole rule exists to fix.\n */\n.jini-md-table th,\n.jini-md-table td {\n padding: 6px 10px;\n min-width: 130px;\n border: 1px solid var(--jini-chat-border);\n text-align: left;\n}\n.jini-md-table th {\n background: var(--jini-chat-subtle);\n color: var(--jini-chat-text-strong);\n font-weight: 650;\n}\n.jini-md-table-expand {\n margin-top: 4px;\n padding: 4px 10px;\n color: var(--jini-chat-muted);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 999px;\n font-size: 11px;\n cursor: pointer;\n}\n.jini-md-table-expand:hover { color: var(--jini-chat-text-strong); }\n.jini-md-table-modal {\n max-width: min(90vw, 900px);\n max-height: 80vh;\n overflow: auto;\n padding: 20px;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: 0 18px 46px rgba(13, 12, 10, .15), 0 2px 8px rgba(13, 12, 10, .06);\n}\n.jini-md-table-modal::backdrop {\n background: rgba(0, 0, 0, .4);\n}\n.jini-md-table-modal-close {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 28px;\n height: 28px;\n color: var(--jini-chat-muted);\n background: transparent;\n border: 0;\n border-radius: 6px;\n font-size: 16px;\n line-height: 1;\n cursor: pointer;\n}\n.jini-md-table-modal-close:hover {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n}\n@media (max-width: 560px) {\n .jini-chat-pane__header { padding-inline: 16px; }\n .jini-chat-pane .jini-message-list { padding-inline: 16px; }\n .jini-chat-pane__controls { padding-inline: 10px; }\n}\n/*\n * Attachment preview modal (MessageRow.tsx's chip -> AttachmentPreviewModal.tsx). Same\n * native-<dialog> shape as .jini-md-table-modal above (own box sized to content, not viewport;\n * ::backdrop dims the page), kept as its own class rather than reusing that one because the two\n * differ in intent: a table pop-out is a fixed-chrome expansion of content already on screen, this\n * is a general image/text/metadata viewer with its own header and a dedicated close control.\n */\n.jini-attachment-preview-dialog {\n display: flex;\n flex-direction: column;\n width: min(90vw, 720px);\n max-height: 80vh;\n padding: 0;\n color: var(--jini-chat-text);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 12px;\n box-shadow: 0 18px 46px rgba(13, 12, 10, .15), 0 2px 8px rgba(13, 12, 10, .06);\n}\n.jini-attachment-preview-dialog::backdrop {\n background: rgba(0, 0, 0, .4);\n}\n.jini-attachment-preview-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--jini-chat-border-soft);\n}\n.jini-attachment-preview-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 650;\n color: var(--jini-chat-text-strong);\n}\n.jini-attachment-preview-close {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 26px;\n height: 26px;\n margin-left: auto;\n padding: 0;\n color: var(--jini-chat-muted);\n background: none;\n border: 0;\n border-radius: 6px;\n cursor: pointer;\n}\n.jini-attachment-preview-close:hover {\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-subtle);\n}\n.jini-attachment-preview-body {\n display: flex;\n flex: 1;\n min-height: 0;\n align-items: center;\n justify-content: center;\n padding: 16px;\n overflow: auto;\n}\n.jini-attachment-preview-image {\n display: block;\n max-width: 100%;\n max-height: calc(80vh - 90px);\n object-fit: contain;\n margin: auto;\n}\n.jini-attachment-preview-text {\n width: 100%;\n max-height: calc(80vh - 90px);\n margin: 0;\n padding: 12px;\n overflow: auto;\n color: var(--jini-chat-text);\n background: var(--jini-chat-subtle);\n border: 1px solid var(--jini-chat-border-soft);\n border-radius: 8px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n line-height: 1.5;\n white-space: pre-wrap;\n word-break: break-word;\n}\n.jini-attachment-preview-truncated {\n width: 100%;\n margin-top: 8px;\n color: var(--jini-chat-muted);\n font-size: 11px;\n text-align: center;\n}\n.jini-attachment-preview-loading {\n color: var(--jini-chat-muted);\n font-size: 13px;\n font-style: italic;\n}\n.jini-attachment-preview-meta {\n display: flex;\n flex-direction: column;\n gap: 6px;\n width: 100%;\n max-width: 360px;\n color: var(--jini-chat-text);\n font-size: 13px;\n}\n.jini-attachment-preview-meta-row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n.jini-attachment-preview-meta-label {\n color: var(--jini-chat-faint);\n}\n.jini-attachment-preview-meta-note {\n margin-top: 6px;\n color: var(--jini-chat-muted);\n font-size: 12px;\n}\n/* Previous/next across a message's (or the composer's) images: a footer under the image, so the\n * buttons sit on the panel colour - readable over any picture - side by side in the bottom-right. */\n/* A fixed stage while stepping: sized to each image, the dialog would resize and move the buttons\n * out from under the pointer between clicks. */\n.jini-attachment-preview-dialog.has-nav .jini-attachment-preview-body {\n flex: 0 0 auto;\n height: min(calc(80vh - 152px), 480px);\n}\n.jini-attachment-preview-dialog.has-nav .jini-attachment-preview-image,\n.jini-attachment-preview-dialog.has-nav .jini-attachment-preview-text {\n max-height: 100%;\n}\n.jini-attachment-preview-nav {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--jini-chat-border-soft);\n}\n.jini-attachment-preview-count {\n margin-right: auto;\n color: var(--jini-chat-muted);\n font-size: 12px;\n font-variant-numeric: tabular-nums;\n}\n.jini-attachment-preview-step {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n padding: 0;\n color: var(--jini-chat-text-strong);\n background: var(--jini-chat-panel);\n border: 1px solid var(--jini-chat-border);\n border-radius: 8px;\n cursor: pointer;\n}\n.jini-attachment-preview-step:hover {\n background: var(--jini-chat-subtle);\n border-color: var(--jini-chat-border-strong);\n}\n.jini-attachment-preview-step:focus-visible {\n outline: 2px solid var(--jini-chat-accent);\n outline-offset: 2px;\n}\n/* Phones: 44px targets. The 26px close \"x\" keeps its size inside an invisible 44px area (centred,\n so it stays within the header's 12px/14px padding); the bordered previous/next buttons just grow. */\n@media (max-width: 640px), (pointer: coarse) {\n .jini-attachment-preview-close { position: relative; }\n .jini-attachment-preview-close::before {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n width: 44px;\n height: 44px;\n transform: translate(-50%, -50%);\n }\n .jini-attachment-preview-step {\n width: 44px;\n height: 44px;\n }\n}\n";
2
2
  //# sourceMappingURL=styles.d.ts.map
@@ -538,12 +538,14 @@ export const CHAT_PANE_STYLES = `
538
538
  overflow: visible;
539
539
  padding: 0;
540
540
  }
541
+ /* Half the tray's width, so a sidebar-width pane fits two chips per row and its 88px cap shows two
542
+ rows (four chips) before scrolling; at full width each chip took a row and the third was hidden. */
541
543
  .jini-chat-pane .jini-attachment-chip {
542
544
  display: inline-flex;
543
545
  align-items: center;
544
546
  box-sizing: border-box;
545
547
  min-width: 0;
546
- max-width: min(100%, 290px);
548
+ max-width: min(calc(50% - 3px), 290px);
547
549
  height: 36px;
548
550
  padding: 3px 4px 3px 5px;
549
551
  color: var(--jini-chat-text);
@@ -553,6 +555,7 @@ export const CHAT_PANE_STYLES = `
553
555
  box-shadow: 0 1px 1px rgba(13, 12, 10, .025);
554
556
  animation: jini-chat-chip-in .16s ease both;
555
557
  }
558
+ .jini-chat-pane .jini-composer-leading .jini-attachment-chip { max-width: min(100%, 290px); }
556
559
  @keyframes jini-chat-chip-in {
557
560
  from { opacity: 0; transform: translateY(3px) scale(.94); }
558
561
  to { opacity: 1; transform: none; }
@@ -565,6 +568,29 @@ export const CHAT_PANE_STYLES = `
565
568
  align-items: center;
566
569
  min-width: 0;
567
570
  gap: 7px;
571
+ /* A button (opens the preview modal), reset to the chip's own look. */
572
+ padding: 0;
573
+ font: inherit;
574
+ color: inherit;
575
+ text-align: left;
576
+ background: none;
577
+ border: 0;
578
+ border-radius: 7px;
579
+ cursor: pointer;
580
+ }
581
+ .jini-chat-pane .jini-attachment-chip-body:focus-visible {
582
+ outline: 2px solid var(--jini-chat-accent);
583
+ outline-offset: 2px;
584
+ }
585
+ /* A staged image this browser holds: the thumbnail takes the icon tile's place, same size. */
586
+ .jini-chat-pane .jini-attachment-chip-thumb {
587
+ display: block;
588
+ flex: 0 0 auto;
589
+ width: 26px;
590
+ height: 26px;
591
+ object-fit: cover;
592
+ border-radius: 7px;
593
+ background: var(--jini-chat-subtle);
568
594
  }
569
595
  .jini-chat-pane .jini-attachment-chip-icon {
570
596
  display: grid;
@@ -617,6 +643,21 @@ export const CHAT_PANE_STYLES = `
617
643
  background: var(--jini-chat-subtle);
618
644
  outline: none;
619
645
  }
646
+ /* Phones: the 25px "x" gets an invisible 44px tap area; the chip keeps its size. The area ends at the
647
+ chip's bottom edge and grows up into the tray's 8px top padding / row gap, because growing down
648
+ past the last row would make the 88px tray scroll by a few pixels. */
649
+ @media (max-width: 640px), (pointer: coarse) {
650
+ .jini-chat-pane .jini-attachment-remove { position: relative; }
651
+ .jini-chat-pane .jini-attachment-remove::before {
652
+ content: '';
653
+ position: absolute;
654
+ bottom: -5.5px;
655
+ left: 50%;
656
+ width: 44px;
657
+ height: 44px;
658
+ transform: translateX(-50%);
659
+ }
660
+ }
620
661
  .jini-chat-pane .jini-composer-input {
621
662
  display: block;
622
663
  box-sizing: border-box;
@@ -1702,5 +1743,70 @@ export const CHAT_PANE_STYLES = `
1702
1743
  color: var(--jini-chat-muted);
1703
1744
  font-size: 12px;
1704
1745
  }
1746
+ /* Previous/next across a message's (or the composer's) images: a footer under the image, so the
1747
+ * buttons sit on the panel colour - readable over any picture - side by side in the bottom-right. */
1748
+ /* A fixed stage while stepping: sized to each image, the dialog would resize and move the buttons
1749
+ * out from under the pointer between clicks. */
1750
+ .jini-attachment-preview-dialog.has-nav .jini-attachment-preview-body {
1751
+ flex: 0 0 auto;
1752
+ height: min(calc(80vh - 152px), 480px);
1753
+ }
1754
+ .jini-attachment-preview-dialog.has-nav .jini-attachment-preview-image,
1755
+ .jini-attachment-preview-dialog.has-nav .jini-attachment-preview-text {
1756
+ max-height: 100%;
1757
+ }
1758
+ .jini-attachment-preview-nav {
1759
+ display: flex;
1760
+ align-items: center;
1761
+ justify-content: flex-end;
1762
+ gap: 8px;
1763
+ padding: 10px 14px;
1764
+ border-top: 1px solid var(--jini-chat-border-soft);
1765
+ }
1766
+ .jini-attachment-preview-count {
1767
+ margin-right: auto;
1768
+ color: var(--jini-chat-muted);
1769
+ font-size: 12px;
1770
+ font-variant-numeric: tabular-nums;
1771
+ }
1772
+ .jini-attachment-preview-step {
1773
+ display: inline-flex;
1774
+ align-items: center;
1775
+ justify-content: center;
1776
+ width: 40px;
1777
+ height: 40px;
1778
+ padding: 0;
1779
+ color: var(--jini-chat-text-strong);
1780
+ background: var(--jini-chat-panel);
1781
+ border: 1px solid var(--jini-chat-border);
1782
+ border-radius: 8px;
1783
+ cursor: pointer;
1784
+ }
1785
+ .jini-attachment-preview-step:hover {
1786
+ background: var(--jini-chat-subtle);
1787
+ border-color: var(--jini-chat-border-strong);
1788
+ }
1789
+ .jini-attachment-preview-step:focus-visible {
1790
+ outline: 2px solid var(--jini-chat-accent);
1791
+ outline-offset: 2px;
1792
+ }
1793
+ /* Phones: 44px targets. The 26px close "x" keeps its size inside an invisible 44px area (centred,
1794
+ so it stays within the header's 12px/14px padding); the bordered previous/next buttons just grow. */
1795
+ @media (max-width: 640px), (pointer: coarse) {
1796
+ .jini-attachment-preview-close { position: relative; }
1797
+ .jini-attachment-preview-close::before {
1798
+ content: '';
1799
+ position: absolute;
1800
+ top: 50%;
1801
+ left: 50%;
1802
+ width: 44px;
1803
+ height: 44px;
1804
+ transform: translate(-50%, -50%);
1805
+ }
1806
+ .jini-attachment-preview-step {
1807
+ width: 44px;
1808
+ height: 44px;
1809
+ }
1810
+ }
1705
1811
  `;
1706
1812
  //# sourceMappingURL=styles.js.map
@@ -113,11 +113,19 @@
113
113
  }
114
114
 
115
115
  .jini-attachment-remove,
116
- .jini-attachment-chip-icon {
116
+ .jini-attachment-chip-icon,
117
+ .jini-attachment-chip-thumb {
117
118
  flex: 0 0 auto;
118
119
  }
119
120
 
120
- /* chip-body is a <span> wrapping an icon <span> and a copy <span> — both inline by default, so
121
+ /* A staged image's thumbnail, in the icon's place; without a size the raw image renders full-size. */
122
+ .jini-attachment-chip-thumb {
123
+ width: 26px;
124
+ height: 26px;
125
+ object-fit: cover;
126
+ }
127
+
128
+ /* chip-body is a <button> (opens the preview) wrapping an icon/thumbnail and a copy <span>, so
121
129
  without flex here the (post-fix, block-level) copy element drops to its own line under the
122
130
  icon instead of sitting beside it. */
123
131
  .jini-attachment-chip-body {
@@ -1,5 +1,8 @@
1
1
  import type { DurableRecovery, RecoveryPorts } from "../../core/durable-runs/ports.js";
2
2
  export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
3
- /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot. */
4
- export declare function createDurableRecovery(ports: RecoveryPorts, _optional?: {}): DurableRecovery;
3
+ /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
4
+ * Each recovery decision logs one `logger.info` line (default console) of run metadata. */
5
+ export declare function createDurableRecovery(ports: RecoveryPorts, { logger }?: {
6
+ logger?: Pick<Console, "info">;
7
+ }): DurableRecovery;
5
8
  //# sourceMappingURL=recover.d.ts.map
@@ -22,7 +22,22 @@ function attemptDiagnostics({ run }, _optional = {}) {
22
22
  break;
23
23
  }
24
24
  }
25
- return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => event.kind === "status" ? [event.label, event.detail ?? ""] : event.kind === "raw" ? [event.line] : []).join("\n");
25
+ return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => {
26
+ if (event.kind === "status")
27
+ return [event.label, event.detail ?? ""];
28
+ if (event.kind !== "raw")
29
+ return [];
30
+ try {
31
+ const result = JSON.parse(event.line);
32
+ // Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
33
+ if (result?.type === "result" && result.is_error === false)
34
+ return [];
35
+ }
36
+ catch {
37
+ // Non-JSON stdout can still contain the executor's failure reason.
38
+ }
39
+ return [event.line];
40
+ }).join("\n");
26
41
  }
27
42
  function attemptFailure(run) {
28
43
  const diagnostics = attemptDiagnostics({ run }, {});
@@ -40,7 +55,7 @@ function attemptFailure(run) {
40
55
  const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
41
56
  if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
42
57
  return "permanent";
43
- if (/another answer in this chat is still running|invalid[_ -]?request|context[_ -]?(length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics))
58
+ if (/another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics))
44
59
  return "permanent";
45
60
  return "retryable";
46
61
  }
@@ -60,8 +75,9 @@ function budgetNotice(run, now) {
60
75
  return EXHAUSTED_NOTICE;
61
76
  return null;
62
77
  }
63
- /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot. */
64
- export function createDurableRecovery(ports, _optional = {}) {
78
+ /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
79
+ * Each recovery decision logs one `logger.info` line (default console) of run metadata. */
80
+ export function createDurableRecovery(ports, { logger = console } = {}) {
65
81
  async function finalize(run, notice, canceled = false) {
66
82
  await ports.cancelAttempt(run, {}).catch(() => undefined);
67
83
  // Persist the notice as an event: hook errors are live-only and would leave a reloaded
@@ -108,12 +124,12 @@ export function createDurableRecovery(ports, _optional = {}) {
108
124
  ports.attach(next, {});
109
125
  return "continued";
110
126
  }
111
- async function decide(run, trigger, liveRunId) {
127
+ async function decide(run, trigger, liveRunId, seen) {
112
128
  if (exclusion(run))
113
129
  return finalize(run, "Stopped. Saved work is above.", true);
114
130
  // A terminal executor end confirms this attempt is dead even when the daemon retains its
115
131
  // replayable record (HTTP 200). Other triggers must obtain fresh liveness evidence.
116
- const probe = trigger === "attempt-failed" ? "dead" : liveRunId === run.runId ? "live" : await ports.probe(run, {});
132
+ const probe = seen.probe = trigger === "attempt-failed" ? "dead" : liveRunId === run.runId ? "live" : await ports.probe(run, {});
117
133
  if (probe === "live") {
118
134
  await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
119
135
  ports.attach(run, {});
@@ -122,7 +138,7 @@ export function createDurableRecovery(ports, _optional = {}) {
122
138
  // Diagnostics decide only the fate of an attempt that is no longer running. Read before the
123
139
  // probe, raw stdout quoting a tool description ("OAuth-authenticated") canceled a live turn
124
140
  // as "Not logged in" (luvira, 2026-10-08).
125
- const failure = attemptFailure(run);
141
+ const failure = seen.failure = attemptFailure(run);
126
142
  if (failure === "auth-failure" || failure === "inactivity-watchdog")
127
143
  return finalize(run, failure === "auth-failure" ? "Not logged in. Saved work is above." : "Stopped. Saved work is above.", true);
128
144
  // A bridge failure shows its own reason (and stderr): the generic notice hides what needs repair.
@@ -156,7 +172,13 @@ export function createDurableRecovery(ports, _optional = {}) {
156
172
  return "terminal";
157
173
  if (expectedRunId && expectedRunId !== run.runId)
158
174
  return "superseded";
159
- return decide(run, trigger, options.liveRunId);
175
+ const seen = {};
176
+ const decision = await decide(run, trigger, options.liveRunId, seen);
177
+ // The watcher re-confirms its own live run every resolution window; log only real recovery
178
+ // decisions. Metadata only: diagnostics, answers and cancel reasons can hold secrets.
179
+ if (options.liveRunId !== run.runId)
180
+ logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe: seen.probe, decision, failureClass: seen.failure }));
181
+ return decision;
160
182
  },
161
183
  };
162
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -302,13 +302,13 @@
302
302
  "@electric-sql/pglite": "0.5.8",
303
303
  "pg": "8.23.0",
304
304
  "@ag-ui/core": "0.0.58",
305
+ "@jini-ai/db": "0.3.4",
306
+ "@jini-ai/core": "^0.4.6",
305
307
  "@jini-ai/agent-runtime": "0.4.11",
306
- "@jini-ai/ui": "^0.5.3",
307
- "@jini-ai/agentic": "^0.4.4",
308
+ "@jini-ai/diagnostics": "^0.5.3",
308
309
  "@jini-ai/protocol": "^0.4.4",
309
- "@jini-ai/db": "0.3.4",
310
- "@jini-ai/diagnostics": "^0.5.2",
311
- "@jini-ai/core": "^0.4.6"
310
+ "@jini-ai/ui": "^0.5.4",
311
+ "@jini-ai/agentic": "^0.4.4"
312
312
  },
313
313
  "typesVersions": {
314
314
  "*": {