@jini-ai/chat 0.6.15 → 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 (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +6 -1
  3. package/dist/core/accepted-input.d.ts +12 -0
  4. package/dist/core/accepted-input.js +29 -1
  5. package/dist/core/antigravity/tool-presentation.d.ts +13 -0
  6. package/dist/core/antigravity/tool-presentation.js +20 -0
  7. package/dist/core/assistant-client/ag-ui-transport.js +13 -1
  8. package/dist/core/assistant-client/transport.js +2 -0
  9. package/dist/core/events.d.ts +5 -1
  10. package/dist/core/messages.d.ts +2 -0
  11. package/dist/core/tool-events.d.ts +18 -8
  12. package/dist/core/tool-events.js +72 -16
  13. package/dist/core/tool-presentation.d.ts +14 -0
  14. package/dist/core/tool-presentation.js +8 -0
  15. package/dist/core/transport.d.ts +2 -0
  16. package/dist/core/user-text-redaction.d.ts +2 -1
  17. package/dist/core/user-text-redaction.js +37 -4
  18. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +99 -0
  19. package/dist/embed-widget/browser/frontend-session-bridge.js +384 -0
  20. package/dist/embed-widget/browser/session-store.d.ts +3 -9
  21. package/dist/embed-widget/browser/session-store.js +6 -38
  22. package/dist/embed-widget/react/embed.d.ts +3 -10
  23. package/dist/embed-widget/react/embed.js +8 -52
  24. package/dist/embed-widget/vanilla/index.d.ts +0 -5
  25. package/dist/embed-widget/vanilla/index.js +4 -14
  26. package/dist/embed-widget/vanilla/state.d.ts +3 -11
  27. package/dist/embed-widget/vanilla/state.js +6 -37
  28. package/dist/embed-widget/vanilla/style.css +1 -1
  29. package/dist/react/agent-bridge/frontend-session-bridge.d.ts +2 -56
  30. package/dist/react/agent-bridge/frontend-session-bridge.js +2 -248
  31. package/dist/react/components/Composer.js +2 -1
  32. package/dist/react/components/CopyMessageButton.d.ts +4 -1
  33. package/dist/react/components/CopyMessageButton.js +3 -2
  34. package/dist/react/components/FolderChips.d.ts +13 -0
  35. package/dist/react/components/FolderChips.js +32 -0
  36. package/dist/react/components/Icon.d.ts +1 -1
  37. package/dist/react/components/Icon.js +2 -0
  38. package/dist/react/components/Markdown.d.ts +1 -0
  39. package/dist/react/components/Markdown.js +1 -1
  40. package/dist/react/components/MessageList.d.ts +2 -1
  41. package/dist/react/components/MessageList.js +2 -1
  42. package/dist/react/components/MessageRow.d.ts +3 -0
  43. package/dist/react/components/MessageRow.js +12 -9
  44. package/dist/react/components/MidRunUserMessage.js +3 -2
  45. package/dist/react/components/ToolCard.d.ts +3 -1
  46. package/dist/react/components/ToolCard.js +77 -58
  47. package/dist/react/components/ToolCard.rules.d.ts +20 -0
  48. package/dist/react/components/ToolCard.rules.js +92 -0
  49. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +18 -6
  50. package/dist/react/features/chat-pane/components/ChatPane.js +4 -3
  51. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -1
  52. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +42 -19
  53. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  54. package/dist/react/features/chat-pane/styles.js +96 -9
  55. package/dist/react/features/chat-pane/types.d.ts +7 -0
  56. package/dist/react/hooks/composer-draft-cache.d.ts +9 -6
  57. package/dist/react/hooks/composer-draft-cache.js +24 -0
  58. package/dist/react/hooks/useComposer.d.ts +7 -0
  59. package/dist/react/hooks/useComposer.js +31 -4
  60. package/dist/react/hooks/useConversation.d.ts +2 -0
  61. package/dist/react/hooks/useConversation.js +4 -1
  62. package/dist/react/hooks/useUserTextGuard.d.ts +1 -0
  63. package/dist/react/hooks/useUserTextGuard.js +3 -1
  64. package/dist/react/styles/reference.css +6 -0
  65. package/dist/react/tool-call-display.d.ts +11 -0
  66. package/dist/react/tool-call-display.js +59 -2
  67. package/dist/store/legacy/sqlite/messages.d.ts +12 -10
  68. package/dist/store/legacy/sqlite/messages.js +4 -1
  69. package/dist/store/runtime/durable-run-store.js +35 -3
  70. package/dist/store/sql/store.js +5 -1
  71. package/package.json +91 -86
  72. package/dist/core/browser/page-actions.d.ts +0 -121
  73. package/dist/core/browser/page-actions.js +0 -115
  74. package/dist/embed-widget/browser/page-actions.d.ts +0 -2
  75. package/dist/embed-widget/browser/page-actions.js +0 -3
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { pickerModelValue, pickerCatalogNote, pickerModelOptions, shortModelLabel } from '../model-options.js';
3
+ import { useId } from 'react';
3
4
  import { createPortal } from 'react-dom';
4
5
  import { AgentIcon, RemixIcon, SearchableModelSelect } from '@jini-ai/ui';
6
+ import { InfoTip } from '@jini-ai/ui/admin-widgets';
5
7
  import { assessNativeToolPolicy } from '@jini-ai/agent-runtime/native-tool-policy';
6
8
  import { useT } from '../../../hooks/context.js';
7
9
  import { definedProps } from '../../../util/defined-props.js';
@@ -18,27 +20,37 @@ export function runtimeAgentStatus({ agent }) {
18
20
  return 'Installed · sign-in required';
19
21
  return agent.version ?? 'Installed';
20
22
  }
23
+ /**
24
+ * An agent's `fullCliAccessNote` as a warning icon whose tip (hover, focus, tap) holds the text.
25
+ * It was a paragraph beside the model picker that squeezed the composer row (owner 2026-10-30).
26
+ * `descriptionId` lets the agent's own radio in the picker list read the same text.
27
+ */
28
+ function FullAccessNoteTip({ note, descriptionId, t }) {
29
+ return (_jsx("span", { className: "jini-runtime-access-note", children: _jsx(InfoTip, { label: t(note), triggerLabel: t('Full computer access'), descriptionId: descriptionId, placement: "top", icon: _jsx(RemixIcon, { name: "alert-line", size: 16 }) }) }));
30
+ }
21
31
  /** Selection preflight uses the launch assessor; it never substitutes for enforcement. */
22
32
  function NativeToolPolicyWarning({ agent, policy, isApi }) {
23
33
  const t = useT();
24
34
  if (!agent || isApi)
25
35
  return null;
26
36
  const warning = policy ? assessNativeToolPolicy({ def: agent, policy }) : null;
27
- return _jsxs(_Fragment, { children: [agent.fullCliAccessNote ? _jsx("p", { children: t(agent.fullCliAccessNote) }) : null, warning ? _jsx("p", { role: "alert", children: t(warning) }) : null] });
37
+ return _jsxs(_Fragment, { children: [agent.fullCliAccessNote ? _jsx(FullAccessNoteTip, { note: agent.fullCliAccessNote, t: t }) : null, warning ? _jsx("p", { role: "alert", children: t(warning) }) : null] });
28
38
  }
29
39
  function RuntimeModeButtons({ executionMode, daemonOnline, apiModeAvailable, onExecutionModeChange, t, }) {
30
40
  const localModeLabel = executionMode === 'local' ? t('active') : '';
31
41
  return (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: `jini-runtime-mode${executionMode === 'local' ? ' is-active' : ''}`, disabled: !daemonOnline && executionMode !== 'local', onClick: () => onExecutionModeChange?.('local'), children: [_jsx(RemixIcon, { name: "terminal-box-line", size: 18 }), _jsx("span", { children: t('Use Local CLI') }), _jsx("span", { className: "jini-runtime-mode__meta", children: !daemonOnline ? t('offline') : localModeLabel }), executionMode === 'local' ? (_jsx(RemixIcon, { name: "check-line", size: 19, className: "jini-runtime-check" })) : null] }), _jsxs("button", { type: "button", className: `jini-runtime-mode${executionMode === 'api' ? ' is-active' : ''}`, disabled: !apiModeAvailable, onClick: () => onExecutionModeChange?.('api'), children: [_jsx(RemixIcon, { name: "link", size: 18 }), _jsx("span", { children: t('Use API · BYOK') }), !apiModeAvailable ? _jsx("span", { className: "jini-runtime-mode__meta", children: t('not configured') }) : null, executionMode === 'api' ? (_jsx(RemixIcon, { name: "check-line", size: 19, className: "jini-runtime-check" })) : null] })] }));
32
42
  }
33
43
  function RuntimeAgentList({ orderedAgents, value, onChange, agentIconBasePath, t, }) {
44
+ const noteIdPrefix = useId();
34
45
  if (orderedAgents.length === 0) {
35
46
  return _jsx("div", { className: "jini-runtime-empty", children: t('No available code agents') });
36
47
  }
37
- return (_jsx(_Fragment, { children: orderedAgents.map((agent) => {
38
- const active = agent.id === value.agentId;
39
- const status = runtimeAgentStatus({ agent: agent });
40
- return (_jsxs("button", { type: "button", role: "radio", "aria-checked": active, className: `jini-runtime-agent${active ? ' is-active' : ''}`, "aria-current": active ? 'true' : undefined, "aria-label": `${agent.name} · ${status}`, onClick: () => onChange(defaultChatPaneSelection({ agent: agent })), children: [_jsx(AgentIcon, { id: agent.id, size: 20, className: "jini-runtime-agent-icon", ...definedProps({ source: { basePath: agentIconBasePath } }) }), _jsxs("span", { className: "jini-runtime-agent__copy", children: [_jsx("strong", { children: agent.name }), agent.fullCliAccessNote ? _jsx("small", { children: t(agent.fullCliAccessNote) }) : null] }), agent.supportsTools === false ? (_jsx("span", { className: "jini-runtime-agent__badge", title: t("This CLI has no MCP support, so it cannot use this app's tools."), children: t('No tools') })) : null, _jsx("span", { className: "jini-runtime-agent__status", children: active ? t('selected') : status }), active ? (_jsx(RemixIcon, { name: "check-line", size: 19, className: "jini-runtime-check" })) : null] }, agent.id));
41
- }) }));
48
+ return (_jsx(_Fragment, { children: orderedAgents.map((agent) => (_jsx(RuntimeAgentRow, { agent: agent, active: agent.id === value.agentId, noteId: agent.fullCliAccessNote ? `${noteIdPrefix}-${agent.id}-note` : undefined, onChange: onChange, agentIconBasePath: agentIconBasePath, t: t }, agent.id))) }));
49
+ }
50
+ function RuntimeAgentRow({ agent, active, noteId, onChange, agentIconBasePath, t }) {
51
+ const status = runtimeAgentStatus({ agent: agent });
52
+ // The note's icon is a control of its own, so it sits beside the radio, never inside it.
53
+ return (_jsxs("div", { className: "jini-runtime-agent-row", children: [_jsxs("button", { type: "button", role: "radio", "aria-checked": active, className: `jini-runtime-agent${active ? ' is-active' : ''}`, "aria-current": active ? 'true' : undefined, "aria-label": `${agent.name} · ${status}`, ...(noteId ? { 'aria-describedby': noteId } : {}), onClick: () => onChange(defaultChatPaneSelection({ agent: agent })), children: [_jsx(AgentIcon, { id: agent.id, size: 20, className: "jini-runtime-agent-icon", ...definedProps({ source: { basePath: agentIconBasePath } }) }), _jsx("span", { className: "jini-runtime-agent__copy", children: _jsx("strong", { children: agent.name }) }), agent.supportsTools === false ? (_jsx("span", { className: "jini-runtime-agent__badge", title: t("This CLI has no MCP support, so it cannot use this app's tools."), children: t('No tools') })) : null, _jsx("span", { className: "jini-runtime-agent__status", children: active ? t('selected') : status }), active ? (_jsx(RemixIcon, { name: "check-line", size: 19, className: "jini-runtime-check" })) : null] }), agent.fullCliAccessNote ? _jsx(FullAccessNoteTip, { note: agent.fullCliAccessNote, descriptionId: noteId, t: t }) : null] }));
42
54
  }
43
55
  /**
44
56
  * The composer bar's "Model ⌄" and "Effort ⌄" drop-ups for the selected CLI. These replace the
@@ -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
  },
@@ -302,11 +303,11 @@ function buildChatPaneSlots({ surfaceSlots, embed, renderRuntimePicker }, { lead
302
303
  footerAccessories: embed ? undefined : renderRuntimePicker(),
303
304
  } });
304
305
  }
305
- function ChatPaneTranscript({ pane, emptyState, embed, suggestions, onSelectSuggestion, pendingPrompts, projectFileNameSet, toolCallDisplay, showUsageLine, showThinking, t, }) {
306
+ function ChatPaneTranscript({ pane, sendingLabel, emptyState, embed, suggestions, onSelectSuggestion, pendingPrompts, projectFileNameSet, toolCallDisplay, showUsageLine, showThinking, t, }) {
306
307
  if (pane.conversation.messages.length === 0 && emptyState) {
307
308
  return (_jsxs("div", { className: "jini-message-list jini-message-list--empty", children: [emptyState, embed && _jsx(ChatPaneSuggestionsRow, { suggestions: suggestions, onSelect: onSelectSuggestion, t: t })] }));
308
309
  }
309
- return (_jsx(MessageList, { messages: pane.conversation.messages, isStreaming: pane.conversation.isStreaming, scrollIntent: pane.conversation.scrollIntent, onScrolled: pane.conversation.acknowledgeScroll, pendingPrompts: pendingPrompts, toolCallDisplay: toolCallDisplay, showUsageLine: showUsageLine, showThinking: showThinking, ...(projectFileNameSet === undefined ? {} : { projectFileNames: projectFileNameSet }) }));
310
+ return (_jsx(MessageList, { messages: pane.conversation.messages, ...definedProps({ source: { sendingLabel } }), isStreaming: pane.conversation.isStreaming, scrollIntent: pane.conversation.scrollIntent, onScrolled: pane.conversation.acknowledgeScroll, pendingPrompts: pendingPrompts, toolCallDisplay: toolCallDisplay, showUsageLine: showUsageLine, showThinking: showThinking, ...(projectFileNameSet === undefined ? {} : { projectFileNames: projectFileNameSet }) }));
310
311
  }
311
312
  /** The controls-area suggestions row shows on every workspace surface, and on the embed surface
312
313
  * only when the empty transcript has no `emptyState` to host it instead. */
@@ -404,6 +405,6 @@ export function ChatPane(props) {
404
405
  const selectSuggestion = useChatPaneSuggestion({ rootRef, setDraft: pane.composer.setDraft });
405
406
  const openLinkInNewTab = useChatPaneNewTabLinks();
406
407
  const { pendingPrompts, projectFileNameSet } = useChatPaneTranscriptProps({ pane }, definedProps({ source: { projectFileNames: p.projectFileNames } }));
407
- return (_jsxs("section", { className: chatPaneClassName(p.variant, p.className), style: p.style, "data-activity": pane.activity, ref: rootRef, onClickCapture: openLinkInNewTab, children: [_jsx("style", { "data-jini-chat-pane-styles": "true", children: CHAT_PANE_STYLES }), p.header ?? defaultHeader(p.title, pane.reset, t), p.agentPluginsMode !== 'off' && _jsx(AgentPluginUsage, { conversationId: pane.conversation.conversationId, messages: pane.conversation.messages }), _jsxs("div", { className: "jini-chat-pane__body", children: [_jsx(ChatPaneTranscript, { pane: pane, emptyState: p.emptyState, embed: surface.embed, suggestions: p.suggestions, onSelectSuggestion: selectSuggestion, pendingPrompts: pendingPrompts, projectFileNameSet: projectFileNameSet, toolCallDisplay: p.toolCallDisplay, showUsageLine: p.showUsageLine, showThinking: p.showThinking, t: t }), _jsxs("div", { className: "jini-chat-pane__controls", ref: controlsRef, children: [showsControlsSuggestions(surface.embed, pane.conversation.messages.length, p.emptyState) && (_jsx(ChatPaneSuggestionsRow, { suggestions: p.suggestions, onSelect: selectSuggestion, t: t })), pane.secretRedacted ? _jsx("div", { className: "jini-chat-pane__notice", role: "status", children: t(SECRET_REDACTED_NOTICE) }) : null, _jsx(ChatPaneStatusMessages, { unavailable: unavailable, scanningAgents: runtimeView.scanningAgents, connectingAgents: runtimeView.connectingAgents, executionMode: p.executionMode, apiModeAvailable: p.apiModeAvailable, onExecutionModeChange: p.onExecutionModeChange, sendError: pane.conversation.sendError, onRetrySend: () => void pane.retrySend(), attachmentError: pane.attachmentError, dropReadError: fileDrop.dropReadError, workingDirectoryError: pane.workingDirectoryError, workingDirectoryPending: pane.workingDirectoryPending, workingDirectoryInvalid: pane.workingDirectoryInvalid, runtimeInventoryError: inventory.runtimeInventoryError, typedAnswerNotice: pane.typedAnswerNotice, t: t }), _jsx(ChatPaneComposerArea, { fileDrop: fileDrop, uploadAttachments: surface.uploadAttachments, pane: pane, disabled: p.disabled, unavailable: unavailable, placeholder: composerPlaceholder, slots: slots, attachmentAccept: p.attachmentAccept, workingDirectoryAccess: p.workingDirectoryAccess, workingDirectoryControlPlacement: surface.workingDirectoryControlPlacement, t: t })] })] }), p.footer] }));
408
+ return (_jsxs("section", { className: chatPaneClassName(p.variant, p.className), style: p.style, "data-activity": pane.activity, ref: rootRef, onClickCapture: openLinkInNewTab, children: [_jsx("style", { "data-jini-chat-pane-styles": "true", children: CHAT_PANE_STYLES }), p.header ?? defaultHeader(p.title, pane.reset, t), p.agentPluginsMode !== 'off' && _jsx(AgentPluginUsage, { conversationId: pane.conversation.conversationId, messages: pane.conversation.messages }), _jsxs("div", { className: "jini-chat-pane__body", children: [_jsx(ChatPaneTranscript, { pane: pane, sendingLabel: p.sendingLabel, emptyState: p.emptyState, embed: surface.embed, suggestions: p.suggestions, onSelectSuggestion: selectSuggestion, pendingPrompts: pendingPrompts, projectFileNameSet: projectFileNameSet, toolCallDisplay: p.toolCallDisplay, showUsageLine: p.showUsageLine, showThinking: p.showThinking, t: t }), _jsxs("div", { className: "jini-chat-pane__controls", ref: controlsRef, children: [showsControlsSuggestions(surface.embed, pane.conversation.messages.length, p.emptyState) && (_jsx(ChatPaneSuggestionsRow, { suggestions: p.suggestions, onSelect: selectSuggestion, t: t })), pane.secretRedacted ? (_jsxs("div", { className: "jini-chat-pane__status jini-chat-pane__status--dismissible", role: "status", children: [_jsx("span", { children: t(SECRET_REDACTED_NOTICE) }), _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: pane.dismissSecretRedacted, children: t('Dismiss') })] })) : null, _jsx(ChatPaneStatusMessages, { unavailable: unavailable, scanningAgents: runtimeView.scanningAgents, connectingAgents: runtimeView.connectingAgents, executionMode: p.executionMode, apiModeAvailable: p.apiModeAvailable, onExecutionModeChange: p.onExecutionModeChange, sendError: pane.conversation.sendError, onRetrySend: () => void pane.retrySend(), attachmentError: pane.attachmentError, dropReadError: fileDrop.dropReadError, workingDirectoryError: pane.workingDirectoryError, workingDirectoryPending: pane.workingDirectoryPending, workingDirectoryInvalid: pane.workingDirectoryInvalid, runtimeInventoryError: inventory.runtimeInventoryError, typedAnswerNotice: pane.typedAnswerNotice, t: t }), _jsx(ChatPaneComposerArea, { fileDrop: fileDrop, uploadAttachments: surface.uploadAttachments, pane: pane, disabled: p.disabled, unavailable: unavailable, placeholder: composerPlaceholder, slots: slots, attachmentAccept: p.attachmentAccept, workingDirectoryAccess: p.workingDirectoryAccess, workingDirectoryControlPlacement: surface.workingDirectoryControlPlacement, t: t })] })] }), p.footer] }));
408
409
  }
409
410
  //# sourceMappingURL=ChatPane.js.map
@@ -44,8 +44,10 @@ export interface UseChatPaneOptions extends UserTextRedactionOptions {
44
44
  deliverMidRunMessage?: DeliverMidRunMessage;
45
45
  }
46
46
  export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
47
- /** Show a localized non-secret notice when a paste was removed. */
47
+ /** Show a localized non-secret notice when a paste was removed. Cleared by the next send or {@link dismissSecretRedacted}. */
48
48
  secretRedacted: boolean;
49
+ /** Hides the redaction notice. */
50
+ dismissSecretRedacted: () => void;
49
51
  conversation: UseConversationResult;
50
52
  composer: UseComposerResult;
51
53
  selection: ChatPaneAgentSelection;
@@ -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,
@@ -680,12 +697,13 @@ export function useChatPane(options) {
680
697
  if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
681
698
  return;
682
699
  setTypedAnswerNotice(null);
700
+ guard.clearNotice();
683
701
  // Until upload completes, an empty attachment list is not evidence of a text-only answer.
684
702
  if (uploadingWhileStreaming) {
685
703
  await sendOrdinaryTurn(prompt);
686
704
  return;
687
705
  }
688
- const typedAnswer = findTypedAnswerTarget({
706
+ const typedAnswer = composer.folders?.length ? null : findTypedAnswerTarget({
689
707
  deliver: options.deliverTypedAnswer,
690
708
  blocker: sendBlocker,
691
709
  attachmentCount: composer.attachments.length,
@@ -701,6 +719,7 @@ export function useChatPane(options) {
701
719
  composer,
702
720
  conversation.isStreaming,
703
721
  conversation.messages,
722
+ guard.clearNotice,
704
723
  options.deliverTypedAnswer,
705
724
  sendBlocker,
706
725
  sendOrdinaryTurn,
@@ -713,23 +732,25 @@ export function useChatPane(options) {
713
732
  }
714
733
  if (sendBlocker !== null || startingPromptRef.current)
715
734
  return;
716
- 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 });
717
736
  }, [send, sendBlocker, sendGuardedPrompt]);
718
737
  const sendAsNewMessage = useCallback(async () => {
719
738
  const prompt = composerPrompt(composer);
720
739
  if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
721
740
  return;
722
741
  setTypedAnswerNotice(null);
742
+ guard.clearNotice();
723
743
  await sendOrdinaryTurn(prompt);
724
- }, [composer, sendOrdinaryTurn]);
744
+ }, [composer, guard.clearNotice, sendOrdinaryTurn]);
725
745
  const interruptSend = useCallback(() => {
726
746
  const prompt = composerPrompt(composer);
727
747
  if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
728
748
  return;
749
+ guard.clearNotice();
729
750
  enqueueTurn({ turn: guard.redact({ text: prompt }, {}) });
730
751
  composer.setDraft('');
731
752
  conversation.cancel();
732
- }, [composer, conversation, enqueueTurn, guard.redact]);
753
+ }, [composer, conversation, enqueueTurn, guard.clearNotice, guard.redact]);
733
754
  /** Abort only uploads owned by the removed inputs; late completions must not refill the composer. */
734
755
  const discardQueuedUploads = useCallback(({ id }, _options = {}) => {
735
756
  for (const [controller, owner] of queuedUploadOwnersRef.current) {
@@ -769,7 +790,7 @@ export function useChatPane(options) {
769
790
  }
770
791
  if (activeUploadCount > 0 || attachmentError !== null)
771
792
  return;
772
- const target = queuedForLiveDeliveryRef.current ? findMidRunTarget({
793
+ const target = queuedForLiveDeliveryRef.current && !queuedInputs.some(input => input.folders?.length) ? findMidRunTarget({
773
794
  deliver: options.deliverMidRunMessage,
774
795
  blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
775
796
  }) : null;
@@ -799,6 +820,7 @@ export function useChatPane(options) {
799
820
  composer.reset();
800
821
  setAttachmentError(null);
801
822
  setTypedAnswerNotice(null);
823
+ guard.clearNotice();
802
824
  typedAnswerGenerationRef.current += 1;
803
825
  // Without this, a prompt queued behind a streaming run survives `reset()` and — once
804
826
  // `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
@@ -808,9 +830,10 @@ export function useChatPane(options) {
808
830
  queuedUploadOwnersRef.current.clear();
809
831
  setQueuedInputs([]);
810
832
  refusedSendRef.current = null;
811
- }, [composer, conversation, options.initialMessages]);
833
+ }, [composer, conversation, guard.clearNotice, options.initialMessages]);
812
834
  return {
813
835
  secretRedacted: guard.secretRedacted,
836
+ dismissSecretRedacted: guard.clearNotice,
814
837
  conversation,
815
838
  composer,
816
839
  selection,