@jini-ai/chat 0.6.15-rc.2 → 0.6.16

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 (126) hide show
  1. package/README.md +6 -1
  2. package/dist/core/accepted-input.js +20 -11
  3. package/dist/core/ag-ui/entry.js +3 -1
  4. package/dist/core/agent-plugin-chat-state.js +3 -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 +14 -2
  8. package/dist/core/assistant-client/attachment-liveness.js +1 -1
  9. package/dist/core/assistant-client/durable-run-subscription.js +2 -1
  10. package/dist/core/assistant-client/transport.js +13 -7
  11. package/dist/core/assistant-content.js +2 -1
  12. package/dist/core/composer-history.js +2 -1
  13. package/dist/core/durable-projection.js +2 -1
  14. package/dist/core/durable-runs/continuation.js +2 -1
  15. package/dist/core/events.d.ts +5 -1
  16. package/dist/core/messages.d.ts +5 -1
  17. package/dist/core/partial-json.js +18 -9
  18. package/dist/core/persistence/title.d.ts +1 -1
  19. package/dist/core/persistence/title.js +25 -7
  20. package/dist/core/question-form/normalize.js +1 -1
  21. package/dist/core/question-form/scan.js +25 -15
  22. package/dist/core/question-form/secret-answers.js +2 -1
  23. package/dist/core/run-activity.js +8 -4
  24. package/dist/core/run-events/entry.js +6 -2
  25. package/dist/core/tool-events.d.ts +18 -8
  26. package/dist/core/tool-events.js +72 -16
  27. package/dist/core/tool-presentation.d.ts +14 -0
  28. package/dist/core/tool-presentation.js +8 -0
  29. package/dist/core/transcript.js +13 -6
  30. package/dist/core/turn-usage.js +7 -2
  31. package/dist/core/user-text-redaction.d.ts +4 -6
  32. package/dist/core/user-text-redaction.js +13 -6
  33. package/dist/core/util/manifest.js +13 -11
  34. package/dist/core/util/markdown-context.d.ts +14 -0
  35. package/dist/core/util/markdown-context.js +42 -4
  36. package/dist/core/util/markup-attributes.js +1 -1
  37. package/dist/core/util/parser.js +7 -7
  38. package/dist/core/util/pointer.js +22 -6
  39. package/dist/core/util/recover.js +5 -8
  40. package/dist/core/util/strip.js +12 -15
  41. package/dist/core/util/validate.js +30 -18
  42. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +99 -0
  43. package/dist/embed-widget/browser/frontend-session-bridge.js +384 -0
  44. package/dist/embed-widget/browser/lazy-loader.js +16 -12
  45. package/dist/embed-widget/browser/session-store.d.ts +3 -9
  46. package/dist/embed-widget/browser/session-store.js +6 -38
  47. package/dist/embed-widget/react/dialog.hooks.js +2 -2
  48. package/dist/embed-widget/react/embed.d.ts +3 -10
  49. package/dist/embed-widget/react/embed.js +8 -52
  50. package/dist/embed-widget/react/header.hooks.js +1 -1
  51. package/dist/embed-widget/transports/fetch-sse.js +2 -1
  52. package/dist/embed-widget/vanilla/index.d.ts +0 -5
  53. package/dist/embed-widget/vanilla/index.js +23 -26
  54. package/dist/embed-widget/vanilla/state.d.ts +3 -11
  55. package/dist/embed-widget/vanilla/state.js +10 -39
  56. package/dist/embed-widget/vanilla/style.css +1 -1
  57. package/dist/react/agent-bridge/frontend-session-bridge.d.ts +2 -56
  58. package/dist/react/agent-bridge/frontend-session-bridge.js +2 -248
  59. package/dist/react/components/AttachmentPreviewModal.hooks.js +2 -1
  60. package/dist/react/components/ChatSettingsDialog.js +18 -9
  61. package/dist/react/components/Composer.js +5 -4
  62. package/dist/react/components/ConversationList.js +5 -4
  63. package/dist/react/components/Icon.d.ts +1 -1
  64. package/dist/react/components/Icon.js +1 -0
  65. package/dist/react/components/Markdown.d.ts +1 -0
  66. package/dist/react/components/Markdown.js +30 -27
  67. package/dist/react/components/McpUiSurfaceCard.js +2 -1
  68. package/dist/react/components/MessageList.d.ts +1 -1
  69. package/dist/react/components/MessageList.hooks.js +3 -2
  70. package/dist/react/components/MessageRow.d.ts +3 -0
  71. package/dist/react/components/MessageRow.js +32 -14
  72. package/dist/react/components/MessageRow.usage.js +3 -1
  73. package/dist/react/components/QuestionForm.js +4 -5
  74. package/dist/react/components/SentAttachment.js +4 -1
  75. package/dist/react/components/TodoCard.js +9 -1
  76. package/dist/react/components/ToolCard.d.ts +3 -1
  77. package/dist/react/components/ToolCard.js +84 -64
  78. package/dist/react/components/ToolCard.rules.d.ts +20 -0
  79. package/dist/react/components/ToolCard.rules.js +92 -0
  80. package/dist/react/components/composer-discovery.js +8 -8
  81. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +1 -2
  82. package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts +1 -1
  83. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +31 -9
  84. package/dist/react/features/chat-pane/components/ChatPane.js +7 -5
  85. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +1 -1
  86. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +1 -1
  87. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +2 -2
  88. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -1
  89. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +13 -5
  90. package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +2 -2
  91. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +1 -1
  92. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +2 -2
  93. package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js +2 -2
  94. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +2 -2
  95. package/dist/react/features/chat-pane/model-options.js +4 -2
  96. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  97. package/dist/react/features/chat-pane/styles.js +95 -5
  98. package/dist/react/features/chat-pane/types.d.ts +11 -0
  99. package/dist/react/hooks/assistant-chats.js +22 -8
  100. package/dist/react/hooks/useArtifactStream.js +2 -1
  101. package/dist/react/hooks/useComposer.js +2 -2
  102. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +2 -2
  103. package/dist/react/hooks/useConversation.js +9 -4
  104. package/dist/react/hooks/useExtEventGroups.js +4 -2
  105. package/dist/react/hooks/useLatestOperation.js +2 -1
  106. package/dist/react/hooks/useQuestionForms.js +32 -22
  107. package/dist/react/hooks/useRunStream.js +4 -2
  108. package/dist/react/hooks/useUserTextGuard.d.ts +1 -0
  109. package/dist/react/hooks/useUserTextGuard.js +3 -1
  110. package/dist/react/message-blocks.js +4 -2
  111. package/dist/react/styles/reference.css +9 -0
  112. package/dist/react/tool-call-display.d.ts +11 -0
  113. package/dist/react/tool-call-display.js +59 -2
  114. package/dist/server/durable-runs/recover.js +13 -5
  115. package/dist/server/run-finalizer/entry.js +1 -0
  116. package/dist/store/runtime/chat-history-store.js +9 -3
  117. package/dist/store/runtime/durable-run-store.js +34 -24
  118. package/dist/store/runtime/run-ledger.js +7 -4
  119. package/dist/store/sql/boundary.js +3 -1
  120. package/dist/store/sql/cursor.js +8 -3
  121. package/dist/store/sql/store.js +36 -28
  122. package/package.json +93 -88
  123. package/dist/core/browser/page-actions.d.ts +0 -121
  124. package/dist/core/browser/page-actions.js +0 -115
  125. package/dist/embed-widget/browser/page-actions.d.ts +0 -2
  126. package/dist/embed-widget/browser/page-actions.js +0 -3
@@ -427,6 +427,13 @@ export const CHAT_PANE_STYLES = `
427
427
  border-radius: 8px;
428
428
  font-size: 12px;
429
429
  }
430
+ .jini-chat-pane__status--dismissible {
431
+ display: flex;
432
+ align-items: flex-start;
433
+ justify-content: space-between;
434
+ gap: 10px;
435
+ }
436
+ .jini-chat-pane__status--dismissible > button { flex: none; }
430
437
  .jini-chat-pane__drop-target {
431
438
  position: relative;
432
439
  margin: -8px -8px -8px;
@@ -717,10 +724,15 @@ export const CHAT_PANE_STYLES = `
717
724
  .jini-chat-pane.jini-chat-pane .jini-composer-input { font-size: 16px; }
718
725
  }
719
726
  .jini-chat-pane .jini-composer-input::placeholder { color: var(--jini-chat-faint); }
727
+ /* Wraps as a last resort: the leading slot gives way first (below), then the model label truncates,
728
+ and only then does the trailing group move to its own line whole. Unwrapped, a picked-folder chip
729
+ in the leading slot squeezed the model picker until send sat on its label (owner report,
730
+ 2026-10-29); wrapping on the group's full width put picker and send on a second row whenever the
731
+ untruncated labels did not fit (owner, 2026-10-30: one line). */
720
732
  .jini-chat-pane .jini-composer-footer {
721
733
  display: flex;
734
+ flex-wrap: wrap;
722
735
  align-items: center;
723
- justify-content: space-between;
724
736
  min-height: 42px;
725
737
  gap: 6px;
726
738
  padding: 6px 8px 8px;
@@ -730,7 +742,14 @@ export const CHAT_PANE_STYLES = `
730
742
  /* Host controls slotted at the start of the footer row, right after the attach/discovery "+"
731
743
  trigger above -- see ComposerSlots.footerLeadingAccessory's own doc (slots.ts) for why this
732
744
  exists as a third footer slot alongside footerAccessories/plusMenuItems. */
733
- .jini-chat-pane .jini-composer-footer-leading { display: inline-flex; align-items: center; }
745
+ /* Basis 0, so a line break is decided on the slot's min-content, not its full width: a chip in it
746
+ truncates before the trailing group wraps. It grows back to its content, never past it. */
747
+ .jini-chat-pane .jini-composer-footer-leading {
748
+ display: inline-flex;
749
+ flex: 1 1 0%;
750
+ align-items: center;
751
+ max-width: max-content;
752
+ }
734
753
  /* NOT 'position: relative' — the discovery popover (below) anchors off '.jini-composer' itself
735
754
  (the nearest positioned ancestor once this wrapper opts out), not off this small trigger-button
736
755
  wrapper. Anchoring to the wrapper put the popover's bottom edge at the wrapper's own top edge —
@@ -951,9 +970,21 @@ export const CHAT_PANE_STYLES = `
951
970
  color: var(--jini-chat-muted);
952
971
  font-size: 12px;
953
972
  }
973
+ /* Picker, plus menu and send: one group at the trailing edge that wraps as a unit. Basis 0 with an
974
+ automatic minimum, like the leading slot: the line break is decided on the group's min-content
975
+ (model label truncated, below), so the label gives way before the group wraps. It grows back to
976
+ its content, never past it. */
977
+ .jini-chat-pane .jini-composer-footer-trailing {
978
+ display: flex;
979
+ flex: 1 1 0%;
980
+ align-items: center;
981
+ gap: 6px;
982
+ max-width: max-content;
983
+ margin-left: auto;
984
+ }
954
985
  /* Always pushed to the trailing edge. This also covers a footer with no attach/accessory controls
955
986
  before the send group (a public visitor chat), which would otherwise leave the lone send button
956
- at the start under the footer's space-between. */
987
+ at the start of the row. */
957
988
  .jini-chat-pane .jini-composer-send-group {
958
989
  display: flex;
959
990
  flex-shrink: 0;
@@ -1177,6 +1208,9 @@ export const CHAT_PANE_STYLES = `
1177
1208
  cursor: pointer;
1178
1209
  }
1179
1210
  .jini-runtime-trigger { gap: 6px; padding: 3px 4px; }
1211
+ /* The model label truncates, but the trigger keeps room for its chevron and a few characters;
1212
+ at the shared 'min-width: 0' it shrank to 12px and the chevron spilled onto the effort label. */
1213
+ .jini-runtime-picker .jini-runtime-dropup-trigger { min-width: 4.5em; }
1180
1214
  /* Local mode's trigger is the icon alone, so it has nothing to give way: shrunk under the shared
1181
1215
  \`min-width: 0\` on a narrow bar, the 22px icon spilled over the model/effort labels beside it
1182
1216
  (owner report, 2026-10-21). API mode keeps shrinking -- its model label ellipsizes. */
@@ -1206,6 +1240,16 @@ export const CHAT_PANE_STYLES = `
1206
1240
  text-overflow: ellipsis;
1207
1241
  white-space: nowrap;
1208
1242
  }
1243
+ /* In the composer footer: one clamped line that may break anywhere, so its min-content is one
1244
+ character and the trailing group's min-content (\`.jini-composer-footer-trailing\`) counts the label
1245
+ at the trigger's 4.5em floor, not its full text. */
1246
+ .jini-chat-pane .jini-composer-footer-trailing .jini-runtime-dropup-trigger__label {
1247
+ display: -webkit-box;
1248
+ -webkit-box-orient: vertical;
1249
+ -webkit-line-clamp: 1;
1250
+ white-space: normal;
1251
+ word-break: break-all;
1252
+ }
1209
1253
  .jini-runtime-trigger__chevron { flex: none; }
1210
1254
  /* The effort label is short; the model label is the one that gives way on a narrow bar. */
1211
1255
  .jini-runtime-dropup-trigger--effort { flex: none; }
@@ -1367,6 +1411,26 @@ export const CHAT_PANE_STYLES = `
1367
1411
  text-overflow: ellipsis;
1368
1412
  white-space: nowrap;
1369
1413
  }
1414
+ /* An agent's full-access note (FullAccessNoteTip): a danger-tinted warning icon whose InfoTip holds
1415
+ the text, in place of the paragraph that squeezed the composer row. 24px hit target; flex: none
1416
+ so it never shrinks or wraps the row. The popover is portalled out of .jini-chat-pane, hence the
1417
+ fallback colour. */
1418
+ .jini-runtime-access-note { display: inline-flex; flex: none; }
1419
+ .jini-runtime-access-note .info-tip { margin-left: 0; }
1420
+ .jini-runtime-access-note .info-tip-icon,
1421
+ .jini-runtime-access-note .info-tip:hover .info-tip-icon,
1422
+ .jini-runtime-access-note .info-tip:focus-within .info-tip-icon {
1423
+ width: 24px;
1424
+ height: 24px;
1425
+ color: var(--jini-chat-danger, #e5484d);
1426
+ border-radius: 6px;
1427
+ }
1428
+ .jini-runtime-access-note .info-tip-icon:hover { background: var(--jini-chat-subtle); }
1429
+ .jini-runtime-access-note .info-tip-icon:focus-visible { outline: 2px solid var(--jini-chat-accent); outline-offset: 1px; border-radius: 6px; }
1430
+ /* The note's icon sits beside its radio, not inside it (a control may not nest in a button). */
1431
+ .jini-runtime-agent-row { display: flex; align-items: center; }
1432
+ .jini-runtime-agent-row > .jini-runtime-agent { flex: 1; min-width: 0; }
1433
+ .jini-runtime-agent-row > .jini-runtime-access-note { margin-right: 4px; }
1370
1434
  .jini-runtime-empty { padding: 10px 11px; color: var(--jini-chat-muted); font-size: 12px; }
1371
1435
  .jini-runtime-models {
1372
1436
  display: flex;
@@ -1453,8 +1517,22 @@ export const CHAT_PANE_STYLES = `
1453
1517
  .jini-chat-pane .op-status { display: inline-flex; flex-shrink: 0; }
1454
1518
  .jini-chat-pane .op-status-ok { color: #4e875f; }
1455
1519
  .jini-chat-pane .op-status-error { color: var(--danger); }
1520
+ .jini-chat-pane .op-status-retried { color: var(--jini-chat-muted); }
1456
1521
  .jini-chat-pane .op-status-running { color: var(--jini-chat-accent); }
1457
- .jini-chat-pane .op-title { font-weight: 600; flex-shrink: 0; }
1522
+ /* The name truncates before the target does (owner bug 11-01: "Content Read Content…" squeezed the
1523
+ target out — the target was flex: 1 with a 0 basis beside a name that would not shrink). A generic
1524
+ row's target (.op-target, ToolCard's ToolRowHead) keeps its own width up to 35% of the row; both
1525
+ carry their full text as a title. Family cards keep .op-meta filling the row after a short name.
1526
+ 35%, not 40%: on the ~333px dock 40% still cut "Content Post Update" to "Content Post Upd…"
1527
+ (checked in Chrome 11-01); 35% shows the whole name and ~12 characters of target. */
1528
+ .jini-chat-pane .op-title {
1529
+ font-weight: 600;
1530
+ flex: 0 1 auto;
1531
+ min-width: 0;
1532
+ overflow: hidden;
1533
+ text-overflow: ellipsis;
1534
+ white-space: nowrap;
1535
+ }
1458
1536
  .jini-chat-pane .shimmer-text { animation: jini-chat-shimmer 1.6s ease-in-out infinite; }
1459
1537
  @keyframes jini-chat-shimmer { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
1460
1538
  @media (prefers-reduced-motion: reduce) { .jini-chat-pane .shimmer-text { animation: none; opacity: .6; } }
@@ -1466,7 +1544,19 @@ export const CHAT_PANE_STYLES = `
1466
1544
  flex: 1;
1467
1545
  min-width: 0;
1468
1546
  }
1469
- .jini-chat-pane .op-expand-chev { flex-shrink: 0; color: var(--jini-chat-faint); }
1547
+ .jini-chat-pane .op-meta.op-target { flex: 0 0 auto; max-width: 35%; }
1548
+ /* ToolRowHead's "Tool call · " prefix: screen readers still hear it, the ~333px dock row doesn't spend
1549
+ half its width on it (owner bug 11-01). Clipped, never display:none, so it stays in the accessible name. */
1550
+ .jini-chat-pane .op-sr-prefix {
1551
+ position: absolute;
1552
+ width: 1px;
1553
+ height: 1px;
1554
+ overflow: hidden;
1555
+ clip: rect(0, 0, 0, 0);
1556
+ white-space: nowrap;
1557
+ clip-path: inset(50%);
1558
+ }
1559
+ .jini-chat-pane .op-expand-chev { flex-shrink: 0; margin-left: auto; color: var(--jini-chat-faint); }
1470
1560
  /* Collapsed by default: 0 max-height clips the inner content, no display:none — keeps the height
1471
1561
  transition instead of an instant jump cut. */
1472
1562
  .jini-chat-pane .accordion-collapsible { max-height: 0; overflow: hidden; transition: max-height .15s ease; }
@@ -1,4 +1,5 @@
1
1
  import type { UserTextRedactionOptions } from '../../../core/user-text-redaction.js';
2
+ import type { NativeToolPolicyInput } from '@jini-ai/agent-runtime/native-tool-policy';
2
3
  import type { CSSProperties, ReactNode, RefObject } from 'react';
3
4
  import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
4
5
  import type { ComposerSlots } from '../../slots.js';
@@ -57,6 +58,10 @@ export interface ChatPaneAgent {
57
58
  * as `true` — no badge — since there is no signal either way.
58
59
  */
59
60
  supportsTools?: boolean;
61
+ /** Native OFF switches can be enforced and audited; absence fails closed when limits exist. */
62
+ supportsNativeToolAccess?: boolean;
63
+ fullCliAccessOnly?: boolean;
64
+ fullCliAccessNote?: string;
60
65
  }
61
66
  export interface ChatPaneAgentSelection {
62
67
  agentId: string;
@@ -194,6 +199,11 @@ export type DeliverMidRunMessage = (input: {
194
199
  }) => Promise<MidRunMessageDelivery>;
195
200
  export interface ChatPaneProps extends UserTextRedactionOptions {
196
201
  transport: ChatTransport;
202
+ /** Host-localized text shown verbatim while the active run is awaiting acceptance.
203
+ * Omit to use the translated `Sending…` label; ignored once sending ends. */
204
+ sendingLabel?: string;
205
+ /** Current effective host limits, used for a warning before the first message is sent. */
206
+ nativeToolPolicy?: NativeToolPolicyInput;
197
207
  agents?: readonly ChatPaneAgent[];
198
208
  /** Optional host effects for package-owned inventory, rescan, and health polling. */
199
209
  runtimeAccess?: ChatPaneRuntimeAccess;
@@ -401,6 +411,7 @@ export interface ByokRuntimeSummary {
401
411
  }
402
412
  export interface AgentRuntimePickerProps {
403
413
  agents: readonly ChatPaneAgent[];
414
+ nativeToolPolicy?: NativeToolPolicyInput;
404
415
  value: ChatPaneAgentSelection;
405
416
  onChange: (selection: ChatPaneAgentSelection) => void;
406
417
  onRescan?: () => void;
@@ -414,7 +414,8 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
414
414
  * conversation that no longer exists and every subsequent message is written at a dead row.
415
415
  */
416
416
  const refresh = useCallback(async () => {
417
- const seq = ++listSeqRef.current;
417
+ listSeqRef.current += 1;
418
+ const seq = listSeqRef.current;
418
419
  // `null` marks a failed read, so the mount-time restore can tell "not listed" from "no list".
419
420
  const read = await portRef.current.listConversations({}, {}).catch(() => null);
420
421
  const listed = read ?? [];
@@ -515,12 +516,13 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
515
516
  * once, already knowing what it holds and what has been persisted.
516
517
  */
517
518
  const select = useCallback((id) => {
518
- const seq = ++switchSeqRef.current;
519
+ switchSeqRef.current += 1;
520
+ const seq = switchSeqRef.current;
519
521
  const commit = (messages) => {
520
522
  // A slower load for a conversation the user has since navigated away from must not land.
521
523
  if (switchSeqRef.current !== seq)
522
524
  return;
523
- writtenRef.current.set(id, new Set(messages.map(message => messageWriteKey({ message }, {}))));
525
+ writtenRef.current.set(id, new Set(messages.map(savedMessageWriteKey)));
524
526
  setInitialMessages(messages);
525
527
  commitActiveId(id);
526
528
  // A user-initiated switch is exactly when remounting the pane IS the intent.
@@ -579,7 +581,8 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
579
581
  * The conversation row is still created either way. That is deliberate — it exists server-side,
580
582
  * and abandoning it silently would be worse than leaving an empty chat in the list.
581
583
  */
582
- const seq = ++switchSeqRef.current;
584
+ switchSeqRef.current += 1;
585
+ const seq = switchSeqRef.current;
583
586
  const conversation = await portRef.current.createConversation({}, {});
584
587
  // Both branches insert locally, so both have to invalidate an in-flight `refresh` — otherwise a
585
588
  // list snapshot taken before this POST lands overwrites the row that was just added.
@@ -660,9 +663,10 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
660
663
  markListMutated();
661
664
  // Held until the PATCH settles, so any refresh landing in between re-applies this title
662
665
  // instead of trusting a server read that has not caught up yet.
663
- const version = ++renameVersionRef.current;
666
+ renameVersionRef.current += 1;
667
+ const version = renameVersionRef.current;
664
668
  pendingRenamesRef.current.set(id, { version, title });
665
- setConversations((current) => current.map((c) => (c.id === id ? { ...c, title, titleSource: "manual" } : c)));
669
+ setConversations((current) => renamedConversations({ current, id, title }));
666
670
  try {
667
671
  await portRef.current.renameConversation({ id, title }, {});
668
672
  }
@@ -685,9 +689,10 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
685
689
  const existing = pending.get(key);
686
690
  if (existing)
687
691
  return existing;
692
+ const isDisposed = () => disposedRef.current;
688
693
  const write = (async () => {
689
694
  await runStubPendingRef.current.get(conversationId)?.get(key);
690
- return saveWithRetry(portRef.current, conversationId, message, () => disposedRef.current);
695
+ return saveWithRetry(portRef.current, conversationId, message, isDisposed);
691
696
  })();
692
697
  pending.set(key, write);
693
698
  void write.then(() => {
@@ -879,7 +884,7 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
879
884
  .createConversation({}, {})
880
885
  .then((conversation) => {
881
886
  markListMutated();
882
- setConversations((current) => [conversation, ...current]);
887
+ setConversations(prependConversation(conversation));
883
888
  writtenRef.current.set(conversation.id, new Set());
884
889
  // Superseded: the row exists and shows up in the list, but the pane that asked for it is
885
890
  // gone, so pointing `activeId` at it would send the NEXT pane's writes here. The caller's
@@ -964,4 +969,13 @@ export function createAssistantChatsClient({ ports }, _options = {}) {
964
969
  useAssistantChats: ({ port }, options = {}) => useAssistantChats(port, options),
965
970
  };
966
971
  }
972
+ function savedMessageWriteKey(message) {
973
+ return messageWriteKey({ message }, {});
974
+ }
975
+ function renamedConversations({ current, id, title }) {
976
+ return current.map(conversation => conversation.id === id ? { ...conversation, title, titleSource: "manual" } : conversation);
977
+ }
978
+ function prependConversation(conversation) {
979
+ return (current) => [conversation, ...current];
980
+ }
967
981
  //# sourceMappingURL=assistant-chats.js.map
@@ -32,8 +32,9 @@ function collectCompleted(head, { registry }) {
32
32
  for (const ev of parseArtifacts({ content: head })) {
33
33
  if (ev.type === 'artifact:start') {
34
34
  meta = { identifier: ev.identifier, artifactType: ev.artifactType, title: ev.title };
35
+ continue;
35
36
  }
36
- else if (ev.type === 'artifact:end') {
37
+ if (ev.type === 'artifact:end') {
37
38
  items.push(toItem({ ...meta, content: ev.fullContent, status: 'complete' }, { registry }));
38
39
  }
39
40
  }
@@ -186,12 +186,12 @@ export function useComposer(options = {}) {
186
186
  const validate = validateAttachmentsRef.current;
187
187
  if (!validate || !conversationId) {
188
188
  attachmentsHydratedForRef.current = conversationId;
189
- return;
189
+ return undefined;
190
190
  }
191
191
  const cached = readCachedAttachments({ conversationId: conversationId });
192
192
  if (cached === null || cached.length === 0) {
193
193
  attachmentsHydratedForRef.current = conversationId;
194
- return;
194
+ return undefined;
195
195
  }
196
196
  let cancelled = false;
197
197
  void (async () => {
@@ -19,12 +19,12 @@ function visualLineBounds({ textarea }) {
19
19
  mirror.append(text, document.createTextNode('\u200b'));
20
20
  document.body.append(mirror);
21
21
  try {
22
- function lineAt(offset) {
22
+ const lineAt = (offset) => {
23
23
  const range = document.createRange();
24
24
  range.setStart(text, offset);
25
25
  range.setEnd(text, offset);
26
26
  return range.getBoundingClientRect().top;
27
- }
27
+ };
28
28
  const first = lineAt(0);
29
29
  const last = lineAt(text.length);
30
30
  // Both selection endpoints must be on the boundary visual line. Selecting across soft
@@ -52,7 +52,7 @@ export function useConversation(options) {
52
52
  useEffect(() => {
53
53
  const source = options.refreshSignal;
54
54
  if (!source || run.isStreaming)
55
- return;
55
+ return undefined;
56
56
  let disposed = false, pending = false, timer;
57
57
  const refresh = async () => {
58
58
  if (pending)
@@ -61,7 +61,7 @@ export function useConversation(options) {
61
61
  try {
62
62
  const saved = await source.loadMessages();
63
63
  if (!disposed)
64
- setMessagesState(prev => [...new Map([...prev, ...saved].map(message => [message.id, message])).values()]);
64
+ setMessagesState(prev => mergeSavedMessages({ previous: prev, saved }));
65
65
  }
66
66
  catch (error) {
67
67
  if (!disposed)
@@ -143,14 +143,14 @@ export function useConversation(options) {
143
143
  return m;
144
144
  // A plain if/else chain (not a nested ternary) — deliberately, so
145
145
  // each of the four mapped statuses is its own independently
146
- // trackable branch under coverage instrumentation. The initial
146
+ // trackable branch under coverage instrumentation. The fallback
147
147
  // value is asserted non-null: this reconciliation effect only ever
148
148
  // runs after `sendMessage`/`retry` has called `run.start()`/
149
149
  // `reattach` (both set the message's `runStatus: 'queued'` via
150
150
  // `activeAssistantIdRef` first), so `m.runStatus` is always defined
151
151
  // and `run.status` is never `'idle'` by the time this callback
152
152
  // fires — one of the four branches below always reassigns it.
153
- let runStatus = m.runStatus;
153
+ let runStatus;
154
154
  if (run.status === 'streaming')
155
155
  runStatus = 'running';
156
156
  else if (run.status === 'done')
@@ -159,6 +159,8 @@ export function useConversation(options) {
159
159
  runStatus = 'failed';
160
160
  else if (run.status === 'canceled')
161
161
  runStatus = 'canceled';
162
+ else
163
+ runStatus = m.runStatus;
162
164
  const nextRunId = run.runId ?? m.runId;
163
165
  const events = projectAcceptedInputEvents({ events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}) });
164
166
  return {
@@ -274,4 +276,7 @@ export function useConversation(options) {
274
276
  suppressScroll,
275
277
  }), [messages, conversationId, run.isStreaming, run.error, sendRefused, scrollIntent, sendMessage, cancel, retry, setMessages, acknowledgeScroll, suppressScroll]);
276
278
  }
279
+ function mergeSavedMessages({ previous, saved }) {
280
+ return [...new Map([...previous, ...saved].map(message => [message.id, message])).values()];
281
+ }
277
282
  //# sourceMappingURL=useConversation.js.map
@@ -26,12 +26,14 @@ function addExtEvent(state, { name, data }) {
26
26
  function visitEvent(state, ev) {
27
27
  if (ev.kind === 'tool_use') {
28
28
  state.openCalls.set(ev.id, { name: ev.name, input: ev.input });
29
+ return;
29
30
  }
30
- else if (ev.kind === 'tool_result') {
31
+ if (ev.kind === 'tool_result') {
31
32
  state.openCalls.delete(ev.toolUseId);
32
33
  state.results.set(ev.toolUseId, { content: ev.content, isError: ev.isError });
34
+ return;
33
35
  }
34
- else if (ev.kind === 'ext') {
36
+ if (ev.kind === 'ext') {
35
37
  addExtEvent(state, ev);
36
38
  }
37
39
  }
@@ -48,7 +48,8 @@ export function useLatestOperation(_requiredArgs) {
48
48
  generationRef.current += 1;
49
49
  }, []);
50
50
  const run = useCallback(async ({ body, onError }) => {
51
- const generation = ++generationRef.current;
51
+ generationRef.current += 1;
52
+ const generation = generationRef.current;
52
53
  const isCurrent = () => mountedRef.current && generation === generationRef.current;
53
54
  const token = {
54
55
  isCurrent,
@@ -66,23 +66,17 @@ export function parseSubmittedAnswers({ form, userMessageContent }) {
66
66
  // `i < lines.length` is the loop condition, so this index is always
67
67
  // in-bounds and defined — same reasoning as `header` above.
68
68
  const line = lines[i];
69
- const m = /^[-*]\s*([^:]+):\s*(.*)$/.exec(line);
70
- if (!m)
69
+ const parsed = submittedAnswerLine({ line });
70
+ if (!parsed)
71
71
  continue;
72
- // Both capture groups are required (non-optional) in this pattern, so
73
- // they're always defined once `m` is non-null — TS just can't express
74
- // that for regex capture groups.
75
- const labelKey = m[1].trim().toLowerCase();
76
- const value = m[2].trim();
77
- const id = labelToId.get(labelKey);
78
- if (!id)
79
- continue;
80
- // `id` only ever comes from `labelToId`, which was populated from this
81
- // same `form.questions` array immediately above, so a question with
82
- // this exact `id` is always found — the `!` reflects that invariant
83
- // rather than gaming an untestable defensive branch.
84
- const question = form.questions.find((x) => x.id === id);
85
- answers[id] = decodeAnswerValue(question, value);
72
+ const id = labelToId.get(parsed.labelKey);
73
+ if (id) {
74
+ // `id` only ever comes from `labelToId`, populated from this same `form.questions` array,
75
+ // so this exact question exists; the assertion reflects that invariant rather than adding
76
+ // an untestable defensive branch.
77
+ const question = form.questions.find((x) => x.id === id);
78
+ answers[id] = decodeAnswerValue(question, parsed.value);
79
+ }
86
80
  }
87
81
  return Object.keys(answers).length > 0 ? answers : null;
88
82
  }
@@ -97,12 +91,28 @@ function decodeAnswerValue(question, value) {
97
91
  return value.toLowerCase() === '(skipped)' ? '' : formOptionValueForLabel({ question: question, labelOrValue: parseSubmittedOptionToken(value) });
98
92
  }
99
93
  function parseSubmittedOptionToken(raw) {
100
- const match = /\s+\[value:\s*([^\]]+)\]\s*$/i.exec(raw);
101
- if (!match)
94
+ const trimmed = raw.trimEnd();
95
+ if (!trimmed.endsWith(']'))
96
+ return raw.trim();
97
+ const end = trimmed.length - 1;
98
+ // The value cannot cross an earlier ']'; search the remaining suffix once, from the start
99
+ // of each whitespace run, retaining the first eligible (possibly nested) value marker.
100
+ const base = trimmed.lastIndexOf(']', end - 1) + 1;
101
+ const marker = /(?<!\s)\s+\[value:/i.exec(trimmed.slice(base, end));
102
+ if (!marker)
102
103
  return raw.trim();
103
- // The single capture group is non-optional in this pattern, so it's
104
- // always defined once `match` is non-null — same regex-capture-group
105
- // caveat as `parseSubmittedAnswers` above.
106
- return match[1].trim();
104
+ const value = trimmed.slice(base + marker.index + marker[0].length, end);
105
+ return value.length > 0 ? value.trim() : raw.trim();
106
+ }
107
+ function submittedAnswerLine({ line }) {
108
+ const label = /^[-*]([^:]+):/.exec(line);
109
+ if (!label)
110
+ return null;
111
+ const value = /^(.*)$/.exec(line.slice(label[0].length).trimStart());
112
+ if (!value)
113
+ return null;
114
+ // Each capture is required in its own pattern and is defined after a successful match;
115
+ // TypeScript cannot express that invariant for RegExpExecArray's numeric capture slots.
116
+ return { labelKey: label[1].trim().toLowerCase(), value: value[1].trim() };
107
117
  }
108
118
  //# sourceMappingURL=useQuestionForms.js.map
@@ -118,7 +118,8 @@ export function useRunStream({ transport }) {
118
118
  }, []);
119
119
  const start = useCallback(async (input) => {
120
120
  teardownSubscription();
121
- const generation = ++generationRef.current;
121
+ generationRef.current += 1;
122
+ const generation = generationRef.current;
122
123
  runIdRef.current = null;
123
124
  const subscriptionAbort = new AbortController();
124
125
  const cancelAbort = new AbortController();
@@ -150,7 +151,8 @@ export function useRunStream({ transport }) {
150
151
  }, [makeHandlers, teardownSubscription, transport]);
151
152
  const reattach = useCallback(async (runId, initialEvents = []) => {
152
153
  teardownSubscription();
153
- const generation = ++generationRef.current;
154
+ generationRef.current += 1;
155
+ const generation = generationRef.current;
154
156
  runIdRef.current = runId;
155
157
  const subscriptionAbort = new AbortController();
156
158
  const cancelAbort = new AbortController();
@@ -7,5 +7,6 @@ export declare function useUserTextGuard(options: UserTextRedactionOptions, _opt
7
7
  }, _options?: {}) => import("../index.js").UserTextRedaction;
8
8
  redactText: (text: string) => string;
9
9
  notifyRedaction: (signal: SecretRedactionSignal) => void;
10
+ clearNotice: () => void;
10
11
  };
11
12
  //# sourceMappingURL=useUserTextGuard.d.ts.map
@@ -14,6 +14,8 @@ export function useUserTextGuard(options, _optional = {}) {
14
14
  return result;
15
15
  }, [options.redactUserText, notifyRedaction]);
16
16
  const redactText = useCallback((text) => redact({ text }, {}).text, [redact]);
17
- return { secretRedacted, redact, redactText, notifyRedaction };
17
+ /** The notice is about one message: the pane clears it when the person sends the next one (a key in that one sets it again) or dismisses it. */
18
+ const clearNotice = useCallback(() => setSecretRedacted(false), []);
19
+ return { secretRedacted, redact, redactText, notifyRedaction, clearNotice };
18
20
  }
19
21
  //# sourceMappingURL=useUserTextGuard.js.map
@@ -100,9 +100,11 @@ function placeEvent({ state, ev }, { slotOf, includeThinking }) {
100
100
  // Emitted per boundary, before any slot dedupe below, so every reply gets its own end marker.
101
101
  if (isReplyBoundary({ event: ev }))
102
102
  placeReplyEnd({ state });
103
- if (ev.kind === 'ext')
103
+ if (ev.kind === 'ext') {
104
104
  placeExt({ state, ev }, { slotOf });
105
- else if (ev.kind === 'tool_use')
105
+ return;
106
+ }
107
+ if (ev.kind === 'tool_use')
106
108
  placeToolUse({ state, id: ev.id });
107
109
  }
108
110
  /**
@@ -43,6 +43,15 @@
43
43
  min-width: 0;
44
44
  }
45
45
 
46
+ /* The picker, plus menu and send share this <div>; flex keeps them on one line, and it wraps
47
+ whole to its own line when the footer runs out of room. */
48
+ .jini-composer-footer-trailing {
49
+ display: flex;
50
+ align-items: center;
51
+ min-width: 0;
52
+ margin-left: auto;
53
+ }
54
+
46
55
  /* The grouped add menu and slash autocomplete anchor to the composer rather than escaping the
47
56
  host's layout. Visual tokens remain the host's responsibility. */
48
57
  .jini-composer,
@@ -32,6 +32,17 @@ export interface ToolDisplayRow {
32
32
  export declare function isFailedToolRow({ row }: {
33
33
  row: Pick<ToolDisplayRow, 'status'>;
34
34
  }): boolean;
35
+ /**
36
+ * Ids of failed rows that a LATER successful call of the same tool and target retried (owner, 2026-10-31: a
37
+ * red "HTTP 400 … page was NOT written" row and "· 2 failed" stayed on screen after the immediate
38
+ * retry wrote the page). Those rows render as a neutral "Retried" and are not counted as failed;
39
+ * a failure nothing later succeeded at stays red. Inputs without target coordinates match by
40
+ * tool alone; a targetless success cannot clear a failure naming a target. Pass one turn's rows.
41
+ * @complexity O(n + target bytes) time and space.
42
+ */
43
+ export declare function retriedToolRowIds({ rows }: {
44
+ rows: readonly ToolDisplayRow[];
45
+ }, _options?: {}): ReadonlySet<string>;
35
46
  /**
36
47
  * Applies a display mode to an interleaved message.
37
48
  *
@@ -1,4 +1,4 @@
1
- import { humanizeToolName } from '../core/index.js';
1
+ import { humanizeToolName, isDelegatedWrapperToolName } from '../core/index.js';
2
2
  /**
3
3
  * Whether a row failed: the tool returned an error, or the run
4
4
  * ended without the tool ever answering (`deriveToolStatus` maps both to `'error'`).
@@ -7,6 +7,62 @@ import { humanizeToolName } from '../core/index.js';
7
7
  export function isFailedToolRow({ row }) {
8
8
  return row.status === 'error';
9
9
  }
10
+ /** The tool a row called: a delegated wrapper's `input.toolId`, else its own name (wrappers share one name). */
11
+ function toolIdentity(row) {
12
+ if (!isDelegatedWrapperToolName({ name: row.name }))
13
+ return row.name;
14
+ const input = row.use?.input;
15
+ const toolId = typeof input === 'object' && input !== null ? input.toolId : undefined;
16
+ return typeof toolId === 'string' && toolId ? toolId : row.name;
17
+ }
18
+ /** Prefer a stable id over editable slugs/paths; normalize input aliases within each kind.
19
+ * @complexity O(target bytes), fixed field count; pure, absent coordinates return undefined.
20
+ */
21
+ function targetCoordinate({ input }, _options = {}) {
22
+ const kinds = [
23
+ ['id', ['postId', 'post_id', 'pageId', 'page_id', 'entryId', 'entry_id', 'id']],
24
+ ['slug', ['slug']],
25
+ ['path', ['path', 'filePath', 'file_path']],
26
+ ];
27
+ for (const [kind, fields] of kinds) {
28
+ const field = fields.find(key => typeof input[key] === 'number' || (typeof input[key] === 'string' && input[key] !== ''));
29
+ if (field !== undefined)
30
+ return [kind, input[field]];
31
+ }
32
+ return undefined;
33
+ }
34
+ /** Target coordinates only: corrected content must not prevent a retry from matching.
35
+ * Wrapper inputs belong to the delegated tool, whose identity toolIdentity already resolves.
36
+ * @complexity O(target bytes); the coordinate list is fixed. Pure, with no input mutation.
37
+ */
38
+ function retryIdentity({ row }, _options = {}) {
39
+ const input = row.use?.input;
40
+ const record = typeof input === 'object' && input !== null ? input : {};
41
+ const target = isDelegatedWrapperToolName({ name: row.name }) ? record.input : record;
42
+ const coordinates = typeof target === 'object' && target !== null ? target : {};
43
+ return JSON.stringify([toolIdentity(row), targetCoordinate({ input: coordinates })]);
44
+ }
45
+ /**
46
+ * Ids of failed rows that a LATER successful call of the same tool and target retried (owner, 2026-10-31: a
47
+ * red "HTTP 400 … page was NOT written" row and "· 2 failed" stayed on screen after the immediate
48
+ * retry wrote the page). Those rows render as a neutral "Retried" and are not counted as failed;
49
+ * a failure nothing later succeeded at stays red. Inputs without target coordinates match by
50
+ * tool alone; a targetless success cannot clear a failure naming a target. Pass one turn's rows.
51
+ * @complexity O(n + target bytes) time and space.
52
+ */
53
+ export function retriedToolRowIds({ rows }, _options = {}) {
54
+ const succeededLater = new Set();
55
+ const retried = new Set();
56
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
57
+ const row = rows[i];
58
+ const tool = retryIdentity({ row });
59
+ if (row.status === 'complete')
60
+ succeededLater.add(tool);
61
+ else if (isFailedToolRow({ row }) && succeededLater.has(tool))
62
+ retried.add(row.id);
63
+ }
64
+ return retried;
65
+ }
10
66
  /** Running = no result yet while the run streams (`'executing'`), or not started (`'inProgress'`). */
11
67
  function isRunningToolRow(row) {
12
68
  return row.status === 'executing' || row.status === 'inProgress';
@@ -109,7 +165,8 @@ export function summarizeToolGroup({ rows }) {
109
165
  if (isRunningToolRow(row))
110
166
  return { kind: 'running', name: humanizeToolName({ name: row.name }, { input: row.use?.input }) };
111
167
  }
112
- return { kind: 'done', count: rows.length, failed: rows.filter((row) => isFailedToolRow({ row })).length };
168
+ const retried = retriedToolRowIds({ rows });
169
+ return { kind: 'done', count: rows.length, failed: rows.filter((row) => isFailedToolRow({ row }) && !retried.has(row.id)).length };
113
170
  }
114
171
  /**
115
172
  * The collapsed line's text. Each phrase is its own translation key (the English string is the key,