@jini-ai/chat 0.6.16 → 0.6.17

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/core/accepted-input.d.ts +12 -0
  3. package/dist/core/accepted-input.js +29 -1
  4. package/dist/core/assistant-client/transport.js +2 -0
  5. package/dist/core/messages.d.ts +2 -0
  6. package/dist/core/transport.d.ts +2 -0
  7. package/dist/core/user-text-redaction.js +30 -4
  8. package/dist/react/components/Composer.js +2 -1
  9. package/dist/react/components/CopyMessageButton.d.ts +4 -1
  10. package/dist/react/components/CopyMessageButton.js +3 -2
  11. package/dist/react/components/FolderChips.d.ts +13 -0
  12. package/dist/react/components/FolderChips.js +32 -0
  13. package/dist/react/components/Icon.d.ts +1 -1
  14. package/dist/react/components/Icon.js +1 -0
  15. package/dist/react/components/MessageList.d.ts +1 -0
  16. package/dist/react/components/MessageList.js +2 -1
  17. package/dist/react/components/MessageRow.js +2 -1
  18. package/dist/react/components/MidRunUserMessage.js +3 -2
  19. package/dist/react/features/chat-pane/components/ChatPane.js +1 -0
  20. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +33 -16
  21. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  22. package/dist/react/features/chat-pane/styles.js +20 -0
  23. package/dist/react/features/chat-pane/types.d.ts +4 -0
  24. package/dist/react/hooks/composer-draft-cache.d.ts +9 -6
  25. package/dist/react/hooks/composer-draft-cache.js +24 -0
  26. package/dist/react/hooks/useComposer.d.ts +7 -0
  27. package/dist/react/hooks/useComposer.js +31 -4
  28. package/dist/react/hooks/useConversation.d.ts +2 -0
  29. package/dist/react/hooks/useConversation.js +4 -1
  30. package/dist/react/styles/reference.css +6 -0
  31. package/dist/store/legacy/sqlite/messages.d.ts +12 -10
  32. package/dist/store/legacy/sqlite/messages.js +4 -1
  33. package/dist/store/runtime/durable-run-store.js +35 -3
  34. package/dist/store/sql/store.js +5 -1
  35. package/package.json +12 -12
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.6.17
2
+
3
+ ### Patch Changes
4
+
5
+ - Carry picked folders as per-message folder references, with draft and sent-message chips, persistence and replay.
6
+ - Preserve ordinary typed paths while redacting glued-on secrets and slash-containing keys.
7
+ - Handle npm 12 pack JSON through the shared parser in isolated-consumer tests.
8
+ - Treat Node builtin dependencies as builtins when copying isolated runtime fixtures.
9
+
1
10
  ## 0.6.13
2
11
 
3
12
  ### Patch Changes
@@ -9,6 +9,8 @@ export interface AcceptedChatInput {
9
9
  answerText?: string;
10
10
  /** Absent for legacy/ordinary inputs; consumed only after a CLI UUID receipt. */
11
11
  deliveryState?: 'queued' | 'consumed';
12
+ /** Picked directory paths, kept byte-exact and separate from upload references. */
13
+ folders?: string[];
12
14
  attachments?: ChatAttachment[];
13
15
  secretRedaction?: ChatMessage['secretRedaction'];
14
16
  /** Wire ids replaced by this receipt remain seen by recall. */
@@ -71,5 +73,15 @@ export declare function projectAcceptedInputEvents({ events }: {
71
73
  export declare function acceptedInputsFromMessages({ messages }: {
72
74
  messages: readonly ChatMessage[];
73
75
  }, _options?: {}): AcceptedChatInput[];
76
+ /** Preserve folder metadata through existing event JSON storage; no attachment lifecycle or schema.
77
+ * Existing per-input receipts already own queued variants and must retain their identities.
78
+ */
79
+ export declare function withFolderReceipt({ message }: {
80
+ message: ChatMessage;
81
+ }, _options?: {}): ChatMessage;
82
+ /** Hydrate the message's folder list from its accepted receipts after a database read. */
83
+ export declare function foldersFromReceipts({ events }: {
84
+ events: unknown;
85
+ }, _options?: {}): string[];
74
86
  export {};
75
87
  //# sourceMappingURL=accepted-input.d.ts.map
@@ -17,9 +17,11 @@ export function projectAcceptedInputMessages({ messages }, _options = {}) {
17
17
  return [message];
18
18
  const turn = { ...message };
19
19
  delete turn.attachments;
20
+ delete turn.folders;
20
21
  delete turn.secretRedaction;
21
22
  // MOVE: Older receipts have no per-input files; retain those legacy files on the final bubble.
22
23
  return inputs.map((input, index) => ({ ...turn, id: input.id, content: input.text,
24
+ folders: input.folders ?? (index === inputs.length - 1 ? message.folders ?? [] : []),
23
25
  attachments: input.attachments ?? (index === inputs.length - 1 ? message.attachments ?? [] : []), ...(input.secretRedaction ? { secretRedaction: input.secretRedaction } : {}) }));
24
26
  });
25
27
  }
@@ -246,7 +248,7 @@ export function acceptedInputsFromMessages({ messages }, _options = {}) {
246
248
  const input = acceptedInputEvent({ event });
247
249
  return input ? [input.data] : [];
248
250
  });
249
- return receipts.length > 0 ? receipts : [{ id: message.id, text: message.content }];
251
+ return receipts.length > 0 ? receipts : [{ id: message.id, text: message.content, ...(message.folders ? { folders: message.folders } : {}) }];
250
252
  }
251
253
  return projectAcceptedInputEvents({ events: message.events ?? [] }).flatMap(event => {
252
254
  const input = acceptedInputEvent({ event });
@@ -256,4 +258,30 @@ export function acceptedInputsFromMessages({ messages }, _options = {}) {
256
258
  });
257
259
  });
258
260
  }
261
+ /** Preserve folder metadata through existing event JSON storage; no attachment lifecycle or schema.
262
+ * Existing per-input receipts already own queued variants and must retain their identities.
263
+ */
264
+ export function withFolderReceipt({ message }, _options = {}) {
265
+ if (message.role !== 'user' || !message.folders?.length)
266
+ return message;
267
+ const events = message.events ?? [];
268
+ if (events.some(event => acceptedInputEvent({ event })))
269
+ return message;
270
+ return { ...message, events: [...events, acceptedInputUserEvent({ input: {
271
+ id: message.id, text: message.content, folders: message.folders,
272
+ ...(message.secretRedaction ? { secretRedaction: message.secretRedaction } : {}),
273
+ ...(message.attachments ? { attachments: message.attachments } : {}),
274
+ } }, { receipt: 'accepted' })] };
275
+ }
276
+ /** Hydrate the message's folder list from its accepted receipts after a database read. */
277
+ export function foldersFromReceipts({ events }, _options = {}) {
278
+ if (!Array.isArray(events))
279
+ return [];
280
+ return events.flatMap(event => {
281
+ if (event === null || typeof event !== 'object')
282
+ return [];
283
+ const folders = acceptedInputEvent({ event })?.data.folders;
284
+ return Array.isArray(folders) ? folders.filter((path) => typeof path === 'string') : [];
285
+ });
286
+ }
259
287
  //# sourceMappingURL=accepted-input.js.map
@@ -375,6 +375,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
375
375
  return {
376
376
  messages: byokMessages({ history: input.history }, {}),
377
377
  ...attachmentIdsField(input),
378
+ ...(input.folders?.length ? { folders: input.folders } : {}),
378
379
  ...conversationIdField(input),
379
380
  agentPluginState: input.context?.["agentPluginState"],
380
381
  userPrompt: input.context?.["userPrompt"],
@@ -584,6 +585,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
584
585
  ...modelField(input),
585
586
  ...reasoningField(input),
586
587
  ...attachmentIdsField(input),
588
+ ...(input.folders?.length ? { folders: input.folders } : {}),
587
589
  ...pluginRefIdsField(input),
588
590
  agentPluginState: input.context?.["agentPluginState"],
589
591
  userPrompt: input.context?.["userPrompt"],
@@ -58,6 +58,8 @@ export interface ChatMessage {
58
58
  lastRunEventId?: string;
59
59
  startedAt?: number;
60
60
  endedAt?: number;
61
+ /** Picked directory paths, kept byte-exact and separate from upload references. */
62
+ folders?: string[];
61
63
  attachments?: ChatAttachment[];
62
64
  }
63
65
  //# sourceMappingURL=messages.d.ts.map
@@ -57,6 +57,8 @@ export interface StartRunInput {
57
57
  history: ChatMessage[];
58
58
  agentId?: string;
59
59
  conversationId?: string | null;
60
+ /** Picked directory paths, kept byte-exact and separate from upload references. */
61
+ folders?: string[];
60
62
  attachments?: ChatAttachment[];
61
63
  context?: RunContext;
62
64
  /** Stops the browser-side subscription; the run continues host-side. */
@@ -25,6 +25,24 @@ function entropy(text) {
25
25
  }
26
26
  return bits;
27
27
  }
28
+ /** Recognize identifier paths, including the fragment after a space in a typed path.
29
+ * Require multiple segments and readable words: arbitrary base64 and long digit-mixed runs
30
+ * must still reach the entropy guard. Specific credential rules have already run. */
31
+ function isIdentifierPath({ text }, _options = {}) {
32
+ const segments = text.replace(/^(?:[a-z]:|~)?[\\/]*/i, '').split(/[\\/]/).filter(Boolean);
33
+ return segments.length >= 2 && segments.every(segment => /^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/.test(segment)
34
+ || segment.split(/[-_.]/).every(word => /^[A-Z]$/.test(word)
35
+ || /^[A-Z]?[a-z]{2,}(?:[A-Z][a-z]{2,}|[0-9]{1,4}(?=[A-Z][a-z]{2,}|$))*$/.test(word)));
36
+ }
37
+ /** A piece of a random key: long, both letter cases, and a character-class change at most positions.
38
+ * Readable names (CamelCase, SCREAMING_CASE, dated file names) change class far less often, and
39
+ * every piece of a 40-character base64 key split by '/' is 12+ characters in most keys. */
40
+ function isOpaqueSegment(segment) {
41
+ if (segment.length < 12 || !/[A-Z]/.test(segment) || !/[a-z]/.test(segment))
42
+ return false;
43
+ const changes = segment.match(/[A-Z](?=[^A-Z])|[a-z](?=[^a-z])|\d(?=\D)/g)?.length ?? 0;
44
+ return changes / (segment.length - 1) >= 0.4;
45
+ }
28
46
  /** Stricter than error redaction: also detects unlabelled long tokens, while retaining ordinary URLs,
29
47
  * filesystem paths, hexadecimal hashes, identifiers and prose. Strong credential syntax inside
30
48
  * URLs or paths is still caught. */
@@ -42,15 +60,23 @@ export function redactUserText({ text }, { secretField = false } = {}) {
42
60
  const base = redactSecretShapes({ text: protectedText, policy: { additionalRules: CHAT_RULES } });
43
61
  let count = base.redactions;
44
62
  // Exempt locations only AFTER specific secret rules. Only quoted paths may contain whitespace;
45
- // unquoted paths end there so an adjacent slash-bearing secret cannot inherit the exemption.
63
+ // unquoted paths and URLs end there, and at , | ; and invisible separators, so an adjacent
64
+ // secret glued after them cannot inherit the exemption.
46
65
  // A quoted path ends at its first matching quote, which must sit on a word boundary, so an
47
66
  // apostrophe later in the line ("don't") cannot close an unterminated quote and exempt the gap.
48
67
  // Require a root boundary so a slash inside an opaque token cannot exempt that token, and a
49
68
  // folder after the root so a bare "/" + token is not mistaken for a path.
50
- const locations = [...base.text.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"']+|(?<!\w)(["'`])(?:~\/|[a-z]:[\\/]|\\\\|\/)[^\r\n"'`\\/]+[\\/](?:(?!\1)[^\r\n])*\1(?!\w)|(?<![\w+\/=\\~.-])(?:~\/|[a-z]:[\\/]|\\\\|\/)[^\s<>"'`\\/]+[\\/][^\s<>"'`]*/gi)]
51
- .map(match => ({ start: match.index, end: match.index + match[0].length }));
69
+ const locations = [
70
+ ...[...base.text.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`,|;\u200B-\u200D\uFEFF]+|(?<!\w)(["'`])(?:~\/|[a-z]:[\\/]|\\\\|\/)[^\r\n"'`\\/]+[\\/](?:(?!\1)[^\r\n])*\1(?!\w)|(?<![\w+\/=\\~.-])(?:~\/|[a-z]:[\\/]|\\\\|\/)[^\s<>"'`,|;\u200B-\u200D\uFEFF\\/]+[\\/][^\s<>"'`,|;\u200B-\u200D\uFEFF]*/gi)]
71
+ .map(match => ({ match, url: /^[a-z][a-z0-9+.-]*:\/\//i.test(match[0]) })),
72
+ // The part of a hand-typed unquoted path after a space (Work/CustomerPortal…) has no root.
73
+ ...[...base.text.matchAll(/[^\s<>"'`,|;\u200B-\u200D\uFEFF]+/g)]
74
+ .filter(match => isIdentifierPath({ text: match[0] })).map(match => ({ match, url: false })),
75
+ ].map(({ match, url }) => ({ start: match.index, end: match.index + match[0].length, url }));
52
76
  let cleaned = base.text.replace(/(?<![A-Za-z0-9_+\/=-])[A-Za-z0-9_+\/=-]{40,}(?![A-Za-z0-9_+\/=-])/g, (candidate, offset) => {
53
- if (locations.some(location => offset >= location.start && offset < location.end))
77
+ const location = locations.find(span => offset >= span.start && offset + candidate.length <= span.end);
78
+ // A key glued straight onto a path (/proj/KEY) is still checked; URLs keep their opaque ids.
79
+ if (location && (location.url || !candidate.split(/[\\/]/).some(isOpaqueSegment)))
54
80
  return candidate;
55
81
  if (/^[a-f0-9]+$/i.test(candidate) || !/[A-Za-z]/.test(candidate))
56
82
  return candidate;
@@ -12,6 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * the slots a host supplies; it does not itself know what a "library
13
13
  * picker" or "session mode" is.
14
14
  */
15
+ import { FolderChips } from './FolderChips.js';
15
16
  import { useEffect, useLayoutEffect, useRef, useState, } from 'react';
16
17
  import { RemixIcon } from '@jini-ai/ui';
17
18
  import { useT } from '../hooks/context.js';
@@ -320,7 +321,7 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
320
321
  event.preventDefault();
321
322
  runComposerHostEffect('attachmentPicker.onFiles', () => attachmentPicker.onFiles(files));
322
323
  }
323
- return (_jsxs("div", { className: "jini-composer", children: [_jsx(AccessorySlot, { className: "jini-composer-leading", children: leadingAccessories }), _jsx(AttachmentTray, { attachments: composer.attachments, onRemove: composer.removeAttachment }), _jsx("textarea", { ref: textareaRef, className: "jini-composer-input", value: composer.draft, placeholder: placeholder ?? t('Send a message…'), disabled: disabled, onChange: (e) => {
324
+ return (_jsxs("div", { className: "jini-composer", children: [_jsx(AccessorySlot, { className: "jini-composer-leading", children: leadingAccessories }), _jsxs("div", { className: "jini-composer-attachments", children: [_jsx(AttachmentTray, { attachments: composer.attachments, onRemove: composer.removeAttachment }), _jsx(FolderChips, { folders: composer.folders, onRemove: composer.removeFolder })] }), _jsx("textarea", { ref: textareaRef, className: "jini-composer-input", value: composer.draft, placeholder: placeholder ?? t('Send a message…'), disabled: disabled, onChange: (e) => {
324
325
  composer.setDraft(e.target.value);
325
326
  setSlashActiveIndex(0);
326
327
  setDismissedSlashDraft(null);
@@ -3,6 +3,8 @@ interface CopyMessageButtonProps {
3
3
  text: string;
4
4
  /** Accessible name while idle; swaps to a "Copied" label once the copy lands. */
5
5
  label: string;
6
+ /** Optional hover tooltip; icon-only buttons outside a message action row have no other visible label. */
7
+ title?: string | undefined;
6
8
  }
7
9
  /**
8
10
  * Quiet, icon-only copy affordance shared by the user- and assistant-message
@@ -11,8 +13,9 @@ interface CopyMessageButtonProps {
11
13
  * the icon swap and the accessible announcement of the transient state.
12
14
  * @param props.text - Exact string to copy, verbatim.
13
15
  * @param props.label - Idle accessible name (already translated by the caller).
16
+ * @param props.title - Optional hover tooltip (already translated); swaps to "Copied" like the label.
14
17
  * @complexity O(1) — one child hook call, no branching beyond the copied-state read.
15
18
  */
16
- export declare function CopyMessageButton({ text, label }: CopyMessageButtonProps): import("react").JSX.Element;
19
+ export declare function CopyMessageButton({ text, label, title }: CopyMessageButtonProps): import("react").JSX.Element;
17
20
  export {};
18
21
  //# sourceMappingURL=CopyMessageButton.d.ts.map
@@ -10,12 +10,13 @@ import { Icon } from './Icon.js';
10
10
  * the icon swap and the accessible announcement of the transient state.
11
11
  * @param props.text - Exact string to copy, verbatim.
12
12
  * @param props.label - Idle accessible name (already translated by the caller).
13
+ * @param props.title - Optional hover tooltip (already translated); swaps to "Copied" like the label.
13
14
  * @complexity O(1) — one child hook call, no branching beyond the copied-state read.
14
15
  */
15
- export function CopyMessageButton({ text, label }) {
16
+ export function CopyMessageButton({ text, label, title }) {
16
17
  const t = useT();
17
18
  const { copied, copy } = useCopyToClipboard();
18
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "jini-message-action-btn", "aria-label": copied ? t('Copied') : label, onClick: () => {
19
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "jini-message-action-btn", "aria-label": copied ? t('Copied') : label, title: title && (copied ? t('Copied') : title), onClick: () => {
19
20
  void copy(text);
20
21
  }, children: _jsx(Icon, { name: copied ? 'check' : 'copy' }) }), _jsx("span", { className: "jini-message-copy-status", "aria-live": "polite", children: copied ? t('Copied to clipboard') : '' })] }));
21
22
  }
@@ -0,0 +1,13 @@
1
+ type OnRemove = (input: {
2
+ path: string;
3
+ }, options?: {}) => void;
4
+ /**
5
+ * Shared composer/sent-message folder indicator; references never participate in uploads.
6
+ * One folder shows its chip directly; a "Folders (n)" toggle would only repeat its name.
7
+ */
8
+ export declare function FolderChips({ folders, onRemove }: {
9
+ folders?: readonly string[] | undefined;
10
+ onRemove?: OnRemove | undefined;
11
+ }): import("react").JSX.Element | null;
12
+ export {};
13
+ //# sourceMappingURL=FolderChips.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useState } from 'react';
3
+ import { useT } from '../hooks/context.js';
4
+ import { CopyMessageButton } from './CopyMessageButton.js';
5
+ import { Icon } from './Icon.js';
6
+ /** Display only: splitting both separators never changes the exact path carried or copied. */
7
+ function folderName({ path }, _options = {}) {
8
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
9
+ }
10
+ /** One folder: last name (full path on hover), copy, and × when removable. */
11
+ function FolderChip({ path, onRemove }) {
12
+ const t = useT();
13
+ const copyLabel = t('Copy folder path: {path}', { path });
14
+ const removeLabel = t('Remove folder: {path}', { path });
15
+ return _jsxs("li", { className: "jini-folder-chips__item", children: [_jsx(Icon, { name: "folder" }), _jsx("span", { className: "jini-folder-chips__name", title: path, "aria-label": path, children: folderName({ path }) }), _jsx(CopyMessageButton, { text: path, label: copyLabel, title: copyLabel }), onRemove && _jsx("button", { type: "button", className: "jini-folder-chips__remove", title: removeLabel, "aria-label": removeLabel, onClick: () => onRemove({ path }), children: _jsx(Icon, { name: "close" }) })] });
16
+ }
17
+ /**
18
+ * Shared composer/sent-message folder indicator; references never participate in uploads.
19
+ * One folder shows its chip directly; a "Folders (n)" toggle would only repeat its name.
20
+ */
21
+ export function FolderChips({ folders = [], onRemove }) {
22
+ const t = useT();
23
+ const [expanded, setExpanded] = useState(false);
24
+ const id = useId();
25
+ if (folders.length === 0)
26
+ return null;
27
+ const list = (listId) => _jsx("ul", { id: listId, className: "jini-folder-chips__list", children: folders.map(path => _jsx(FolderChip, { path: path, onRemove: onRemove }, path)) });
28
+ if (folders.length === 1)
29
+ return _jsx("div", { className: "jini-folder-chips", children: list() });
30
+ return _jsxs("div", { className: "jini-folder-chips", children: [_jsxs("button", { type: "button", className: "jini-folder-chips__toggle", "aria-expanded": expanded, "aria-controls": id, onClick: () => setExpanded(value => !value), children: [_jsx(Icon, { name: "folder" }), _jsx("span", { children: t('Folders ({count})', { count: folders.length }) }), _jsx(Icon, { name: expanded ? 'chevron-down' : 'chevron-right' })] }), expanded && list(id)] });
31
+ }
32
+ //# sourceMappingURL=FolderChips.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-left' | 'chevron-right' | 'attach' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'pencil' | 'copy' | 'retry';
10
+ export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'attach' | 'folder' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'pencil' | 'copy' | 'retry';
11
11
  interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
12
12
  name: IconName;
13
13
  size?: number | string;
@@ -15,6 +15,7 @@ const ICON_GLYPHS = {
15
15
  'chevron-left': _jsx("path", { d: "m15 18-6-6 6-6" }),
16
16
  'chevron-right': _jsx("path", { d: "m9 18 6-6-6-6" }),
17
17
  attach: _jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" }),
18
+ folder: _jsx("path", { d: "M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" }),
18
19
  file: _jsxs(_Fragment, { children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" }), _jsx("path", { d: "M14 2v6h6" }), _jsx("path", { d: "M8 13h8M8 17h6" })] }),
19
20
  image: _jsxs(_Fragment, { children: [_jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }), _jsx("circle", { cx: "9", cy: "9", r: "2" }), _jsx("path", { d: "m21 15-5-5L5 21" })] }),
20
21
  pencil: _jsxs(_Fragment, { children: [_jsx("path", { d: "m16 3 5 5L8 21H3v-5L16 3Z" }), _jsx("path", { d: "m14 5 5 5" })] }),
@@ -3,6 +3,7 @@ import { type MessageRowProps } from './MessageRow.js';
3
3
  interface PendingPrompt {
4
4
  id?: string;
5
5
  text: string;
6
+ folders?: string[];
6
7
  attachments?: ChatAttachment[];
7
8
  onCancel: () => void;
8
9
  }
@@ -10,6 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  * scroll-anchoring state lives in `useMessageListAutoScroll`
11
11
  * (`MessageList.hooks.ts`); see that file for why it is presentational.
12
12
  */
13
+ import { FolderChips } from './FolderChips.js';
13
14
  import { memo, useMemo } from 'react';
14
15
  import { projectAcceptedInputMessages } from '../../core/accepted-input.js';
15
16
  import { isTerminalRunStatus } from '../../core/index.js';
@@ -55,6 +56,6 @@ function QueuedMessage({ prompt, renderAttachment }) {
55
56
  const t = useT();
56
57
  const attachments = prompt.attachments ?? [];
57
58
  const gallery = useAttachmentGallery({ attachments });
58
- return (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { children: t('Queued — sends when this reply finishes') }), _jsx("div", { className: "jini-chat-pane__queued-text", children: prompt.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open, renderAttachment: renderAttachment }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: () => prompt.onCancel(), title: t('Cancel queued message'), children: t('Cancel') }) })] }));
59
+ return (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { children: t('Queued — sends when this reply finishes') }), prompt.folders?.length ? _jsx("div", { className: "jini-message-attachments", children: _jsx(FolderChips, { folders: prompt.folders }) }) : null, _jsx("div", { className: "jini-chat-pane__queued-text", children: prompt.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open, renderAttachment: renderAttachment }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: () => prompt.onCancel(), title: t('Cancel queued message'), children: t('Cancel') }) })] }));
59
60
  }
60
61
  //# sourceMappingURL=MessageList.js.map
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { FolderChips } from './FolderChips.js';
2
3
  import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
3
4
  import { MidRunUserMessage, MID_RUN_USER_MESSAGE_EXT_EVENT_NAME } from './MidRunUserMessage.js';
4
5
  import { useSurfaceAnswerContexts } from './mcp-ui-question-input.js';
@@ -184,7 +185,7 @@ function UserMessageRow({ message, renderAttachment }) {
184
185
  // different messages opening their own attachments independently is fine and expected.
185
186
  // Previous/next steps through this message's images only.
186
187
  const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
187
- 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] }));
188
+ 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?.length || message.folders?.length) ? (_jsxs("div", { className: "jini-message-attachments", children: [_jsx(FolderChips, { folders: message.folders }), (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] }));
188
189
  }
189
190
  function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSending, sendingLabel, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
190
191
  const registry = useExtEventRendererRegistry();
@@ -10,6 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
10
10
  * It is stored as an event of the assistant turn, not a `ChatMessage` of its own, because the agent folded it
11
11
  * into the turn it was already running — there is no separate reply to hang off it.
12
12
  */
13
+ import { FolderChips } from './FolderChips.js';
13
14
  import { acceptedInputEvent } from '../../core/accepted-input.js';
14
15
  import { SentAttachmentButton } from './SentAttachment.js';
15
16
  import { CopyMessageButton } from './CopyMessageButton.js';
@@ -37,7 +38,7 @@ export function MidRunUserMessage({ events, runStreaming = false, transcriptRow
37
38
  return null;
38
39
  const receipt = acceptedInputEvent({ event: { kind: 'ext', name: MID_RUN_USER_MESSAGE_EVENT_NAME, data } });
39
40
  // MOVE: A single live delivery may contain several queued inputs; display their original receipts.
40
- const inputs = receipt?.data.inputs ?? [{ id: field(data, 'id') ?? 'mid-run', text }];
41
+ const inputs = receipt?.data.inputs ?? [receipt?.data ?? { id: field(data, 'id') ?? 'mid-run', text }];
41
42
  // A run that ended without reading the message hands it to the next turn; it is no longer queued here.
42
43
  return _jsx(_Fragment, { children: inputs.map(input => _jsx(MidRunInput, { input: input, transcriptRow: transcriptRow, queued: runStreaming && (input.deliveryState ?? receipt?.data.deliveryState) === 'queued' }, input.id)) });
43
44
  }
@@ -48,7 +49,7 @@ function MidRunInput({ input, queued, transcriptRow }) {
48
49
  const t = useT();
49
50
  const attachments = input.attachments ?? [];
50
51
  const gallery = useAttachmentGallery({ attachments });
51
- return (_jsxs("div", { className: `jini-message jini-message-user${transcriptRow ? '' : ' jini-message-user--mid-run'}`, "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: input.text, label: t('Copy message') }) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
52
+ return (_jsxs("div", { className: `jini-message jini-message-user${transcriptRow ? '' : ' jini-message-user--mid-run'}`, "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), input.folders?.length ? _jsx("div", { className: "jini-message-attachments", children: _jsx(FolderChips, { folders: input.folders }) }) : null, _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: input.text, label: t('Copy message') }) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
52
53
  }
53
54
  /**
54
55
  * Registers {@link MidRunUserMessage} against the ext-event registry.
@@ -271,6 +271,7 @@ function useComposerHandlePublisher({ pane }, { composerHandle }) {
271
271
  if (!composerHandle)
272
272
  return undefined;
273
273
  composerHandle.current = {
274
+ addFolders: pane.composer.addFolders,
274
275
  insertText: (text) => {
275
276
  pane.composer.setDraft(appendComposerDiscovery({ draft: composerDraftRef.current, insertText: text }));
276
277
  },
@@ -50,12 +50,12 @@ const ATTACHMENTS_ONLY_PROMPT = 'Review the attached file(s).';
50
50
  */
51
51
  function composerPrompt(composer) {
52
52
  return composer.draft.trim()
53
- || (composer.attachments.length > 0 ? ATTACHMENTS_ONLY_PROMPT : '');
53
+ || (composer.attachments.length > 0 || composer.folders?.length ? ATTACHMENTS_ONLY_PROMPT : '');
54
54
  }
55
55
  /** History hydration can replace a controller without an edit. Compare the accepted empty draft
56
56
  * and staged files so holding Enter cannot requeue an attachment-only submission on that render. */
57
57
  function isRepeatedComposerSubmission({ composer, submitted }, _options = {}) {
58
- return submitted === composer || (submitted !== null && composer.draft.trim() === '' && submitted.attachments === composer.attachments);
58
+ return submitted === composer || (submitted !== null && composer.draft.trim() === '' && submitted.attachments === composer.attachments && submitted.folders === composer.folders);
59
59
  }
60
60
  function composerDeliveryBlocked({ delivering, starting, composer, submitted }, _options = {}) {
61
61
  return delivering || starting || isRepeatedComposerSubmission({ composer, submitted });
@@ -331,6 +331,8 @@ export function useChatPane(options) {
331
331
  latestComposerRef.current = composer;
332
332
  const latestDraftRef = useRef(composer.draft);
333
333
  latestDraftRef.current = composer.draft;
334
+ const latestFoldersRef = useRef(composer.folders ?? []);
335
+ latestFoldersRef.current = composer.folders ?? [];
334
336
  const latestAttachmentsRef = useRef(composer.attachments);
335
337
  latestAttachmentsRef.current = composer.attachments;
336
338
  useEffect(() => {
@@ -440,6 +442,7 @@ export function useChatPane(options) {
440
442
  const restoreQueuedDraft = useCallback(({ inputs, text, attachments }, _options = {}) => {
441
443
  if (inputs === undefined)
442
444
  return;
445
+ latestComposerRef.current.addFolders({ folders: inputs.flatMap(input => input.folders ?? []) });
443
446
  latestComposerRef.current.setDraft([text, latestDraftRef.current].filter(Boolean).join('\n\n'));
444
447
  const staged = new Set(latestAttachmentsRef.current.map(attachment => attachment.path));
445
448
  for (const attachment of attachments) {
@@ -449,20 +452,21 @@ export function useChatPane(options) {
449
452
  }, []);
450
453
  /** A refused composer send is kept for Retry and goes back only into a composer that is still
451
454
  * empty: text typed (or files staged) while it was sending are never overwritten. */
452
- const restoreRefusedDraft = useCallback(({ redaction, draft, attachments }, _options = {}) => {
453
- refusedSendRef.current = { redaction, draft, attachments };
455
+ const restoreRefusedDraft = useCallback(({ redaction, draft, attachments, folders = [] }, _options = {}) => {
456
+ refusedSendRef.current = { redaction, draft, attachments, folders };
457
+ latestComposerRef.current.addFolders({ folders });
454
458
  if (latestDraftRef.current.trim() !== '' || latestAttachmentsRef.current.length > 0)
455
459
  return;
456
460
  latestComposerRef.current.setDraft(draft);
457
461
  for (const attachment of attachments)
458
462
  latestComposerRef.current.addAttachment(attachment);
459
463
  }, []);
460
- const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft }, _options = {}) => {
464
+ const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft, folders }, _options = {}) => {
461
465
  if (!accepted) {
462
466
  if (composerDraft === undefined)
463
467
  restoreQueuedDraft({ inputs, text: redaction.text, attachments });
464
468
  else
465
- restoreRefusedDraft({ redaction, draft: composerDraft, attachments });
469
+ restoreRefusedDraft({ redaction, draft: composerDraft, attachments, folders });
466
470
  return;
467
471
  }
468
472
  refusedSendRef.current = null;
@@ -471,11 +475,17 @@ export function useChatPane(options) {
471
475
  latestComposerRef.current.setDraft('');
472
476
  latestComposerRef.current.closeMention();
473
477
  }
478
+ // Composer/queued sends already took these references. A later pick, even of the same path,
479
+ // belongs to the next draft and must survive this send's asynchronous acceptance.
480
+ if (composerDraft === undefined && inputs === undefined) {
481
+ for (const path of folders)
482
+ latestComposerRef.current.removeFolder({ path });
483
+ }
474
484
  consumeDeliveredAttachments({ attachments });
475
485
  }, [consumeDeliveredAttachments, restoreQueuedDraft, restoreRefusedDraft]);
476
486
  /** `composerDraft`: the composer text this send already took out of the composer, so the
477
487
  * composer is never cleared again on acceptance and a refusal hands the text back. */
478
- const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments, composerDraft }, _options = {}) => {
488
+ const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments, composerDraft, folders = inputs ? inputs.flatMap(input => input.folders ?? []) : composer.folders ?? [] }, _options = {}) => {
479
489
  const trimmed = redaction.text.trim();
480
490
  assertPromptSendable({ trimmed, sendBlocker });
481
491
  if (startingPromptRef.current)
@@ -494,6 +504,7 @@ export function useChatPane(options) {
494
504
  // Empty attachment arrays remain absent at the transport boundary.
495
505
  attachments: attachments.length === 0 ? undefined : attachments,
496
506
  context,
507
+ folders: folders.length ? folders : undefined,
497
508
  acceptedInputs: inputs,
498
509
  onAccepted: ({ inputs: acceptedInputs }) => {
499
510
  accepted = true;
@@ -503,7 +514,7 @@ export function useChatPane(options) {
503
514
  } }));
504
515
  if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
505
516
  return;
506
- finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft });
517
+ finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft, folders });
507
518
  }
508
519
  finally {
509
520
  startingPromptRef.current = false;
@@ -520,24 +531,27 @@ export function useChatPane(options) {
520
531
  return;
521
532
  const draft = latestDraftRef.current;
522
533
  const attachments = latestAttachmentsRef.current;
534
+ const folders = latestFoldersRef.current;
535
+ for (const path of folders)
536
+ latestComposerRef.current.removeFolder({ path });
523
537
  latestComposerRef.current.setDraft('');
524
538
  latestComposerRef.current.closeMention();
525
539
  for (const attachment of attachments)
526
540
  latestComposerRef.current.removeAttachment(attachment.path);
527
541
  const redaction = guard.redact({ text: prompt.trim() }, {});
528
542
  try {
529
- await sendGuardedPrompt({ redaction, attachments, composerDraft: draft });
543
+ await sendGuardedPrompt({ redaction, attachments, folders, composerDraft: draft });
530
544
  }
531
545
  catch (error) {
532
546
  // A host hook that throws (run context, activity) must not cost the typed text either.
533
- restoreRefusedDraft({ redaction, draft, attachments });
547
+ restoreRefusedDraft({ redaction, draft, attachments, folders });
534
548
  throw error;
535
549
  }
536
550
  }, [guard.redact, restoreRefusedDraft, sendBlocker, sendGuardedPrompt]);
537
551
  // MOVE: Every queue path retains per-message receipts; appendQueuedTurn owns their one-turn delivery.
538
552
  // A turn queued against another conversation is not joined — it would never be flushed there anyway.
539
553
  const enqueueTurn = useCallback(({ turn, inputs, attachments = composer.attachments }, _options = {}) => {
540
- const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
554
+ const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments, folders: composer.folders ?? [],
541
555
  ...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
542
556
  const sameConversation = queuedConversationIdRef.current === options.conversationId;
543
557
  queuedConversationIdRef.current = options.conversationId;
@@ -550,7 +564,9 @@ export function useChatPane(options) {
550
564
  // MOVE: Take files out of the composer without rotating their batch: every queued message ships together.
551
565
  for (const attachment of attachments)
552
566
  latestComposerRef.current.removeAttachment(attachment.path);
553
- }, [composer.attachments, options.conversationId, recordAcceptedInput]);
567
+ for (const path of composer.folders ?? [])
568
+ latestComposerRef.current.removeFolder({ path });
569
+ }, [composer.folders, composer.attachments, options.conversationId, recordAcceptedInput]);
554
570
  const answerWaitingQuestion = useCallback(async ({ deliver, questionId, text }) => {
555
571
  const safeText = guard.redactText(text);
556
572
  const generation = typedAnswerGenerationRef.current;
@@ -647,7 +663,8 @@ export function useChatPane(options) {
647
663
  queuedForLiveDeliveryRef.current = queuedTurn === null || queuedForLiveDeliveryRef.current;
648
664
  }
649
665
  // MOVE: The existing queue owns its files; a later steer must join it to preserve send order.
650
- const target = uploadPending || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
666
+ // Folder context is attached on ordinary run start; live messages have no such envelope.
667
+ const target = uploadPending || composer.folders?.length || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
651
668
  deliver: options.deliverMidRunMessage,
652
669
  blocker: sendBlocker,
653
670
  attachmentCount: composer.attachments.length,
@@ -686,7 +703,7 @@ export function useChatPane(options) {
686
703
  await sendOrdinaryTurn(prompt);
687
704
  return;
688
705
  }
689
- const typedAnswer = findTypedAnswerTarget({
706
+ const typedAnswer = composer.folders?.length ? null : findTypedAnswerTarget({
690
707
  deliver: options.deliverTypedAnswer,
691
708
  blocker: sendBlocker,
692
709
  attachmentCount: composer.attachments.length,
@@ -715,7 +732,7 @@ export function useChatPane(options) {
715
732
  }
716
733
  if (sendBlocker !== null || startingPromptRef.current)
717
734
  return;
718
- await sendGuardedPrompt({ redaction: refused.redaction, attachments: refused.attachments, composerDraft: refused.draft });
735
+ await sendGuardedPrompt({ redaction: refused.redaction, attachments: refused.attachments, composerDraft: refused.draft, folders: refused.folders });
719
736
  }, [send, sendBlocker, sendGuardedPrompt]);
720
737
  const sendAsNewMessage = useCallback(async () => {
721
738
  const prompt = composerPrompt(composer);
@@ -773,7 +790,7 @@ export function useChatPane(options) {
773
790
  }
774
791
  if (activeUploadCount > 0 || attachmentError !== null)
775
792
  return;
776
- const target = queuedForLiveDeliveryRef.current ? findMidRunTarget({
793
+ const target = queuedForLiveDeliveryRef.current && !queuedInputs.some(input => input.folders?.length) ? findMidRunTarget({
777
794
  deliver: options.deliverMidRunMessage,
778
795
  blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
779
796
  }) : null;