@runtypelabs/persona 4.19.0 → 4.21.0

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 (77) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-CG-bLFmk.d.cts → types-B_JdVK9o.d.cts} +2 -0
  4. package/dist/animations/{types-CG-bLFmk.d.ts → types-B_JdVK9o.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui.d.cts +99 -5
  10. package/dist/approval-ui.d.ts +99 -5
  11. package/dist/artifacts-ui.d.cts +99 -5
  12. package/dist/artifacts-ui.d.ts +99 -5
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/context-mentions.d.cts +99 -5
  16. package/dist/context-mentions.d.ts +99 -5
  17. package/dist/event-stream-view.d.cts +99 -5
  18. package/dist/event-stream-view.d.ts +99 -5
  19. package/dist/forms-ui.d.cts +123 -9
  20. package/dist/forms-ui.d.ts +123 -9
  21. package/dist/history-view.d.cts +2 -0
  22. package/dist/history-view.d.ts +2 -0
  23. package/dist/index.cjs +51 -51
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +128 -10
  26. package/dist/index.d.ts +128 -10
  27. package/dist/index.global.js +34 -34
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +51 -51
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js.map +1 -1
  32. package/dist/session-reconnect.d.cts +99 -5
  33. package/dist/session-reconnect.d.ts +99 -5
  34. package/dist/smart-dom-reader.d.cts +99 -5
  35. package/dist/smart-dom-reader.d.ts +99 -5
  36. package/dist/theme-editor-preview.cjs +53 -53
  37. package/dist/theme-editor-preview.d.cts +99 -5
  38. package/dist/theme-editor-preview.d.ts +99 -5
  39. package/dist/theme-editor-preview.js +31 -31
  40. package/dist/theme-editor.d.cts +99 -5
  41. package/dist/theme-editor.d.ts +99 -5
  42. package/dist/theme-reference.cjs +1 -1
  43. package/dist/theme-reference.d.cts +46 -5
  44. package/dist/theme-reference.d.ts +46 -5
  45. package/dist/theme-reference.js +1 -1
  46. package/dist/webmcp-runtime.d.cts +16 -0
  47. package/dist/webmcp-runtime.d.ts +16 -0
  48. package/dist/widget.css +1 -1
  49. package/package.json +1 -1
  50. package/src/client-visitor-history.test.ts +342 -3
  51. package/src/client.test.ts +141 -3
  52. package/src/client.ts +157 -39
  53. package/src/components/composer-actions.ts +27 -1
  54. package/src/components/composer-model-picker.ts +4 -14
  55. package/src/components/composer-overflow-menu.ts +14 -0
  56. package/src/components/composer-parts.ts +20 -0
  57. package/src/generated/runtype-openapi-contract.ts +2 -0
  58. package/src/index-core.ts +2 -0
  59. package/src/session.ts +139 -103
  60. package/src/session.webmcp.test.ts +48 -1
  61. package/src/styles/widget-styles.test.ts +83 -1
  62. package/src/styles/widget.css +50 -14
  63. package/src/theme-reference.ts +1 -1
  64. package/src/types/theme.ts +47 -5
  65. package/src/types.ts +63 -0
  66. package/src/ui.composer-action-visibility.test.ts +202 -0
  67. package/src/ui.composer-models.test.ts +37 -0
  68. package/src/ui.composer-modes.test.ts +82 -0
  69. package/src/ui.composer-overflow.test.ts +78 -0
  70. package/src/ui.history-lifecycle.test.ts +2 -2
  71. package/src/ui.ts +77 -6
  72. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1633 -686
  73. package/src/utils/composer-modes.test.ts +33 -0
  74. package/src/utils/composer-modes.ts +19 -2
  75. package/src/utils/tokens-parity.test.ts +20 -0
  76. package/src/utils/tokens.test.ts +76 -0
  77. package/src/utils/tokens.ts +4 -4
package/src/client.ts CHANGED
@@ -566,6 +566,54 @@ export class AgentWidgetClient {
566
566
  return this.clientSession;
567
567
  }
568
568
 
569
+ /** Persona's built-in reconnect transport for client-token sessions. */
570
+ public async reconnectClientTokenStream(ctx: {
571
+ executionId: string;
572
+ after: string;
573
+ signal: AbortSignal;
574
+ }): Promise<Response> {
575
+ let session = await this.initSession();
576
+ let recovered = false;
577
+
578
+ for (;;) {
579
+ const conversationId = session.conversationId;
580
+ const visitorToken = await this.readVisitorToken();
581
+ if (session.durableRecovery?.enabled !== true || !conversationId || !visitorToken) {
582
+ throw new HistoryClientError(
583
+ 'visitor_token_missing',
584
+ 'Durable recovery is not enabled for this client session'
585
+ );
586
+ }
587
+ const path =
588
+ `${this.clientApiBase()}/v1/client/conversations/` +
589
+ `${encodeURIComponent(conversationId)}/executions/` +
590
+ `${encodeURIComponent(ctx.executionId)}/events`;
591
+ const query = new URLSearchParams({
592
+ sessionId: session.sessionId,
593
+ after: ctx.after,
594
+ });
595
+ const response = await fetch(`${path}?${query}`, {
596
+ method: 'GET',
597
+ headers: {
598
+ 'X-Persona-Version': VERSION,
599
+ 'X-Visitor-Token': visitorToken,
600
+ },
601
+ signal: ctx.signal,
602
+ });
603
+ if (response.status === 401 && !recovered) {
604
+ recovered = true;
605
+ session = await this.recoverFromUnauthorized(
606
+ await this.readErrorCode(response),
607
+ null,
608
+ true
609
+ );
610
+ continue;
611
+ }
612
+ if (!response.ok) throw await this.historyErrorFor(response, true);
613
+ return response;
614
+ }
615
+ }
616
+
569
617
  /**
570
618
  * Initialize session for client token mode.
571
619
  * Called automatically on first message if not already initialized.
@@ -709,13 +757,18 @@ export class AgentWidgetClient {
709
757
  */
710
758
  private async createClientSession(opts: {
711
759
  conversationId?: string;
760
+ durableResume?: boolean;
712
761
  identityProof?: string | null;
713
762
  storedSessionId?: string | null;
714
763
  omitVisitorFields?: boolean;
715
764
  }): Promise<ClientSession> {
716
765
  const historyCapable = this.isHistoryCapable() && !opts.omitVisitorFields;
766
+ // Recovery is negotiated independently from the history UI. New servers
767
+ // return an explicit capability bit; old strict servers reject the
768
+ // additive request field, which the fallback below handles once.
769
+ const durableRecoveryRequested = !opts.omitVisitorFields;
717
770
  let visitorToken: string | null = null;
718
- if (historyCapable) {
771
+ if (historyCapable || durableRecoveryRequested) {
719
772
  // Never read persisted state before the controller's stored-state gate.
720
773
  await this.historyInternals.historyBootstrapReady;
721
774
  visitorToken = await this.readVisitorToken();
@@ -723,12 +776,16 @@ export class AgentWidgetClient {
723
776
 
724
777
  const routed = this.routing();
725
778
  const sessionTargetId = routed.agentId ?? routed.flowId;
726
- const resumeConversationId = historyCapable ? opts.conversationId : undefined;
779
+ const resumeConversationId =
780
+ historyCapable || opts.durableResume ? opts.conversationId : undefined;
727
781
  const requestBody: Record<string, unknown> = {
728
782
  token: this.config.clientToken,
729
783
  ...(sessionTargetId && { flowId: sessionTargetId }),
730
784
  ...(historyCapable && { visitorHistory: true }),
731
- ...(historyCapable && visitorToken ? { visitorToken } : {}),
785
+ ...(durableRecoveryRequested && { durableRecovery: true }),
786
+ ...((historyCapable || durableRecoveryRequested) && visitorToken
787
+ ? { visitorToken }
788
+ : {}),
732
789
  // Independent of the strict conversationId/sessionId union: a proof also
733
790
  // binds the visitor on an ordinary init.
734
791
  ...(historyCapable && opts.identityProof
@@ -741,7 +798,7 @@ export class AgentWidgetClient {
741
798
  : {}),
742
799
  };
743
800
 
744
- const response = await fetch(this.getClientApiUrl('init'), {
801
+ let response = await fetch(this.getClientApiUrl('init'), {
745
802
  method: 'POST',
746
803
  headers: {
747
804
  'Content-Type': 'application/json',
@@ -750,6 +807,22 @@ export class AgentWidgetClient {
750
807
  body: JSON.stringify(requestBody),
751
808
  });
752
809
 
810
+ // Rolling/self-hosted compatibility: pre-recovery servers use a strict
811
+ // init schema and answer 400 to the additive field. Retry once without it;
812
+ // the absent response capability keeps Persona on ordinary streaming.
813
+ if (response.status === 400 && durableRecoveryRequested) {
814
+ const fallbackBody = { ...requestBody };
815
+ delete fallbackBody.durableRecovery;
816
+ response = await fetch(this.getClientApiUrl('init'), {
817
+ method: 'POST',
818
+ headers: {
819
+ 'Content-Type': 'application/json',
820
+ 'X-Persona-Version': VERSION,
821
+ },
822
+ body: JSON.stringify(fallbackBody),
823
+ });
824
+ }
825
+
753
826
  if (!response.ok) {
754
827
  const error = await response.json().catch(() => ({ error: 'Session initialization failed' }));
755
828
  if (historyCapable && response.status === 403 && error.error === 'visitor_history_disabled') {
@@ -763,7 +836,17 @@ export class AgentWidgetClient {
763
836
  }
764
837
  return this.createClientSession({ ...opts, omitVisitorFields: true });
765
838
  }
766
- if (historyCapable && response.status === 401 && error.error === 'visitor_required') {
839
+ if (response.status === 403 && error.error === 'durable_recovery_disabled') {
840
+ throw new HistoryClientError(
841
+ 'history_disabled',
842
+ 'Durable recovery is disabled for this surface'
843
+ );
844
+ }
845
+ if (
846
+ (historyCapable || opts.durableResume) &&
847
+ response.status === 401 &&
848
+ error.error === 'visitor_required'
849
+ ) {
767
850
  throw new HistoryClientError('visitor_required', 'Visitor credential no longer resolves');
768
851
  }
769
852
  if (resumeConversationId && response.status === 404) {
@@ -797,6 +880,7 @@ export class AgentWidgetClient {
797
880
  ...(data.conversationRevision
798
881
  ? { conversationRevision: data.conversationRevision }
799
882
  : {}),
883
+ ...(data.durableRecovery ? { durableRecovery: data.durableRecovery } : {}),
800
884
  ...(data.visitor ? { visitor: data.visitor } : {}),
801
885
  config: {
802
886
  welcomeMessage: data.config.welcomeMessage,
@@ -807,15 +891,13 @@ export class AgentWidgetClient {
807
891
  }
808
892
 
809
893
  private async _doInitSession(): Promise<ClientSession> {
810
- if (!this.isHistoryCapable()) {
811
- const storedSessionId = this.config.getStoredSessionId?.() || null;
812
- const session = await this.createClientSession({ storedSessionId });
813
- this.config.setStoredSessionId?.(session.sessionId);
814
- return session;
815
- }
816
-
817
894
  await this.historyInternals.historyBootstrapReady;
818
895
  const previousConversationId = this.config.getStoredConversationId?.() || null;
896
+ const durableResume =
897
+ this.historyInternals.shouldResumeDurableConversation?.() === true;
898
+ if (!this.isHistoryCapable() && !durableResume) {
899
+ return this.ordinaryInit(previousConversationId, false);
900
+ }
819
901
  const storedToken = await this.readVisitorToken();
820
902
 
821
903
  // Boot resume: reopening the record beats replaying a possibly idle-expired
@@ -824,15 +906,23 @@ export class AgentWidgetClient {
824
906
  try {
825
907
  const resumed = await this.createClientSession({
826
908
  conversationId: previousConversationId,
909
+ durableResume,
827
910
  });
911
+ if (durableResume && resumed.durableRecovery?.enabled !== true) {
912
+ this.historyInternals.setStoredResumableHandle?.(null);
913
+ }
828
914
  return this.finishInit(resumed, previousConversationId, false);
829
915
  } catch (error) {
830
916
  if (
831
917
  !isHistoryClientError(error, 'not_found') &&
832
- !isHistoryClientError(error, 'visitor_required')
918
+ !isHistoryClientError(error, 'visitor_required') &&
919
+ !isHistoryClientError(error, 'history_disabled')
833
920
  ) {
834
921
  throw error;
835
922
  }
923
+ if (durableResume) {
924
+ this.historyInternals.setStoredResumableHandle?.(null);
925
+ }
836
926
  // Record gone or credential dead: exactly one ordinary fallback, never a loop.
837
927
  return this.ordinaryInit(previousConversationId, true);
838
928
  }
@@ -851,7 +941,7 @@ export class AgentWidgetClient {
851
941
  continuityBroken: boolean
852
942
  ): Promise<ClientSession> {
853
943
  const store = this.historyInternals.visitorStore;
854
- if (store && this.isHistoryCapable() && !(await this.readVisitorToken())) {
944
+ if (store && !(await this.readVisitorToken())) {
855
945
  return store.withFirstInitLock(() =>
856
946
  this.doOrdinaryInit(previousConversationId, continuityBroken)
857
947
  );
@@ -893,7 +983,11 @@ export class AgentWidgetClient {
893
983
  * unowned; one immediate re-init with `{visitorToken, sessionId}` claims it.
894
984
  */
895
985
  private async claimFirstConversation(first: ClientSession): Promise<ClientSession> {
896
- if (!first.visitor?.token || this.claimInFlight || !this.isHistoryCapable()) {
986
+ if (
987
+ !first.visitor?.token ||
988
+ this.claimInFlight ||
989
+ (!this.isHistoryCapable() && first.durableRecovery?.enabled !== true)
990
+ ) {
897
991
  return first;
898
992
  }
899
993
  this.claimInFlight = true;
@@ -1754,6 +1848,10 @@ export class AgentWidgetClient {
1754
1848
 
1755
1849
  // Build the standard payload to get context/metadata from middleware
1756
1850
  const basePayload = await this.buildPayload(options.messages);
1851
+ const recoveryVisitorToken =
1852
+ session.durableRecovery?.enabled === true
1853
+ ? await this.readVisitorToken()
1854
+ : null;
1757
1855
 
1758
1856
  // Build the chat request payload with message IDs for feedback tracking
1759
1857
  // Filter out sessionId from metadata if present (it's only for local storage)
@@ -1813,6 +1911,9 @@ export class AgentWidgetClient {
1813
1911
  headers: {
1814
1912
  'Content-Type': 'application/json',
1815
1913
  'X-Persona-Version': VERSION,
1914
+ ...(recoveryVisitorToken
1915
+ ? { 'X-Visitor-Token': recoveryVisitorToken }
1916
+ : {}),
1816
1917
  },
1817
1918
  body: JSON.stringify(chatRequest),
1818
1919
  signal: options.signal,
@@ -2221,7 +2322,7 @@ export class AgentWidgetClient {
2221
2322
  public async resumeFlow(
2222
2323
  executionId: string,
2223
2324
  toolOutputs: Record<string, unknown>,
2224
- options?: { streamResponse?: boolean; signal?: AbortSignal }
2325
+ options?: { streamResponse?: boolean; signal?: AbortSignal; after?: string }
2225
2326
  ): Promise<Response> {
2226
2327
  const isClientToken = this.isClientTokenMode();
2227
2328
  const url = isClientToken
@@ -2236,12 +2337,18 @@ export class AgentWidgetClient {
2236
2337
  // trusting the possibly-stale `this.clientSession`. (core#3889; BugBot
2237
2338
  // PR #214 r3367875360.)
2238
2339
  let resumeSessionId: string | undefined;
2340
+ let resumeVisitorToken: string | null = null;
2239
2341
  if (isClientToken) {
2240
- resumeSessionId = (await this.initSession()).sessionId;
2342
+ const session = await this.initSession();
2343
+ resumeSessionId = session.sessionId;
2344
+ if (session.durableRecovery?.enabled === true) {
2345
+ resumeVisitorToken = await this.readVisitorToken();
2346
+ }
2241
2347
  }
2242
2348
 
2243
2349
  let headers: Record<string, string> = {
2244
2350
  'Content-Type': 'application/json',
2351
+ ...(resumeVisitorToken ? { 'X-Visitor-Token': resumeVisitorToken } : {}),
2245
2352
  ...this.headers
2246
2353
  };
2247
2354
  if (this.getHeaders) {
@@ -2252,6 +2359,7 @@ export class AgentWidgetClient {
2252
2359
  executionId,
2253
2360
  toolOutputs,
2254
2361
  streamResponse: options?.streamResponse ?? true,
2362
+ ...(options?.after ? { after: options.after } : {}),
2255
2363
  };
2256
2364
  // Thread the (refreshed) sessionId through like `/v1/client/chat` does.
2257
2365
  if (resumeSessionId) {
@@ -2737,9 +2845,10 @@ export class AgentWidgetClient {
2737
2845
  // bubble-id segmentation on the wire in place of the legacy `partId`:
2738
2846
  // a new block id means a new bubble, sealed at `text_complete`/tool boundaries.
2739
2847
  let currentTextBlockId: string | null = null;
2740
- // Raw text accumulated for the open flow block before its bubble is
2741
- // materialized — lets a whitespace-only block resolve without a stray bubble.
2742
- let pendingFlowRaw = "";
2848
+ // Raw text accumulated for the open text block, on both the flow and agent
2849
+ // paths — lets a whitespace-only flow block resolve without a stray bubble,
2850
+ // and gives the structured-content parser the whole block on either path.
2851
+ let pendingTextRaw = "";
2743
2852
  // Nested flow-as-tool attribution (PR #4602): a text/reasoning block whose
2744
2853
  // `parentToolCallId` matches a `tool_start.toolCallId` belongs to a flow
2745
2854
  // running as that tool. Keyed by the wire block id, these route the block's
@@ -3134,20 +3243,20 @@ export class AgentWidgetClient {
3134
3243
  finalizeCleanup();
3135
3244
  };
3136
3245
 
3137
- // === Unified flow text channel ===
3138
- // Flow prompt-step text streams as `text_delta` blocks (segmented by
3139
- // `text_start`/`text_complete`) and can be structured JSON, so each block
3140
- // runs through the per-bubble structured-content parser — agent text stays
3141
- // plain. This is the legacy step_delta parser core, re-keyed from `partId`
3142
- // to the wire block-id bubble. The caller materializes the bubble lazily
3143
- // (whitespace-only blocks around tool boundaries never leave a stray bubble)
3144
- // and `step_complete.result.response` reconciles the authoritative final.
3246
+ // === Unified text channel ===
3247
+ // Prompt-step and agent text both stream as `text_delta` blocks (segmented by
3248
+ // `text_start`/`text_complete`) and can be structured JSON, so each block runs
3249
+ // through the per-bubble structured-content parser. This is the legacy
3250
+ // step_delta parser core, re-keyed from `partId` to the wire block-id bubble.
3251
+ // The caller materializes the bubble lazily (whitespace-only blocks around
3252
+ // tool boundaries never leave a stray bubble) and `step_complete.result.response`
3253
+ // reconciles the authoritative final.
3145
3254
  let lastSealedFlowBubble: AgentWidgetMessage | null = null;
3146
3255
 
3147
- // Stream one accumulated chunk of flow block text through the parser, setting
3256
+ // Stream one accumulated chunk of block text through the parser, setting
3148
3257
  // display `content` (extracted) + `rawContent` (raw) and emitting. Mirrors the
3149
3258
  // legacy step_delta chunk path; plain text bypasses the structured parser.
3150
- const applyFlowTextChunk = (
3259
+ const applyTextChunk = (
3151
3260
  assistant: AgentWidgetMessage,
3152
3261
  accumulatedRaw: string,
3153
3262
  chunk: string,
@@ -3773,7 +3882,7 @@ export class AgentWidgetClient {
3773
3882
  }
3774
3883
  currentTextBlockId =
3775
3884
  typeof payload.id === "string" ? payload.id : currentTextBlockId;
3776
- pendingFlowRaw = "";
3885
+ pendingTextRaw = "";
3777
3886
  } else if (payloadType === "text_delta") {
3778
3887
  // Nested flow-as-tool text: route to the parent tool's row, through the
3779
3888
  // same structured-content parser, never the top-level assistant channel.
@@ -3793,7 +3902,7 @@ export class AgentWidgetClient {
3793
3902
  executionId: payload.executionId,
3794
3903
  parentToolId: nestedParent,
3795
3904
  };
3796
- applyFlowTextChunk(nested, nestedRaw, nestedDelta, undefined);
3905
+ applyTextChunk(nested, nestedRaw, nestedDelta, undefined);
3797
3906
  continue;
3798
3907
  }
3799
3908
  currentTextBlockId =
@@ -3804,27 +3913,36 @@ export class AgentWidgetClient {
3804
3913
  // block-id bubble. Materialize lazily so a whitespace-only block
3805
3914
  // (newlines around a tool boundary) never leaves a stray bubble.
3806
3915
  const delta = typeof payload.delta === "string" ? payload.delta : "";
3807
- pendingFlowRaw += delta;
3808
- if (pendingFlowRaw.trim() === "") continue;
3916
+ pendingTextRaw += delta;
3917
+ if (pendingTextRaw.trim() === "") continue;
3809
3918
  const assistant = ensureAssistantMessage();
3810
3919
  assistant.agentMetadata = {
3811
3920
  executionId: payload.executionId,
3812
3921
  iteration: payload.iteration,
3813
3922
  };
3814
- applyFlowTextChunk(assistant, pendingFlowRaw, delta, undefined);
3923
+ applyTextChunk(assistant, pendingTextRaw, delta, undefined);
3815
3924
  lastAssistantInTurn = assistant;
3816
3925
  continue;
3817
3926
  }
3927
+ // Agent text can be structured JSON too, whenever the host configures a
3928
+ // custom `streamParser` (e.g. the page-context demo's `{"text": ...}` /
3929
+ // `{"action": "add_to_cart", ...}` envelope). Accumulate the open block's
3930
+ // raw text and run it through the same structured-content path the flow
3931
+ // branch uses, so the parser sees the whole block and `rawContent` stays
3932
+ // populated for the action manager. With the default plain-text parser
3933
+ // this is byte-identical to appending the delta, so agent demos that
3934
+ // stream prose are unaffected.
3935
+ const agentDelta = typeof payload.delta === "string" ? payload.delta : "";
3936
+ pendingTextRaw += agentDelta;
3818
3937
  const assistant = ensureAssistantMessage();
3819
- assistant.content += payload.delta ?? '';
3820
3938
  assistant.agentMetadata = {
3821
3939
  executionId: payload.executionId,
3822
3940
  iteration: payload.iteration,
3823
3941
  turnId: openTurnId ?? undefined,
3824
3942
  agentName: agentExecution?.agentName
3825
3943
  };
3944
+ applyTextChunk(assistant, pendingTextRaw, agentDelta, undefined);
3826
3945
  lastAssistantInTurn = assistant;
3827
- emitMessage(assistant);
3828
3946
  } else if (payloadType === "text_complete") {
3829
3947
  // Nested flow-as-tool text block close: seal its parent-tool-row message.
3830
3948
  const completeBlockId = typeof payload.id === "string" ? payload.id : null;
@@ -3857,7 +3975,7 @@ export class AgentWidgetClient {
3857
3975
  assistantMessage = null;
3858
3976
  }
3859
3977
  currentTextBlockId = null;
3860
- pendingFlowRaw = "";
3978
+ pendingTextRaw = "";
3861
3979
  } else if (payloadType === "step_complete") {
3862
3980
  // Only process completions for prompt steps, not tool/context steps
3863
3981
  const stepType = (payload as any).stepType;
@@ -4201,7 +4319,7 @@ export class AgentWidgetClient {
4201
4319
  assistantMessage = null;
4202
4320
  }
4203
4321
  currentTextBlockId = null;
4204
- pendingFlowRaw = "";
4322
+ pendingTextRaw = "";
4205
4323
  lastSealedFlowBubble = null;
4206
4324
 
4207
4325
  // `terminal: true` marks this as a graceful finish (not a drop). The
@@ -32,6 +32,7 @@ import type {
32
32
  ComposerActionOverflowConfig,
33
33
  ComposerActionPlacement,
34
34
  ComposerActionPresentation,
35
+ ComposerActionVisibility,
35
36
  ComposerButtonAction,
36
37
  ComposerCustomAction,
37
38
  ComposerState,
@@ -345,6 +346,28 @@ const evaluate = (
345
346
  return typeof value === "function" ? value(state) === true : value === true;
346
347
  };
347
348
 
349
+ /**
350
+ * The draft state `sendButton.visibility` reads: text, a pending attachment, or
351
+ * a live stream all count as "there is something in flight".
352
+ */
353
+ const hasDraftContent = (state: Readonly<ComposerState>): boolean =>
354
+ state.phase === "streaming" ||
355
+ state.text.trim().length > 0 ||
356
+ state.attachments.length > 0;
357
+
358
+ /**
359
+ * `ComposerAction.visibility` against the current draft. Evaluated on every
360
+ * state-driven pass, so the answer is always render-time, never patched on.
361
+ */
362
+ export function matchesComposerActionVisibility(
363
+ visibility: ComposerActionVisibility | undefined,
364
+ state: Readonly<ComposerState>
365
+ ): boolean {
366
+ if (!visibility || visibility === "always") return true;
367
+ const drafting = hasDraftContent(state);
368
+ return visibility === "when-text" ? drafting : !drafting;
369
+ }
370
+
348
371
  const isCustom = (action: ComposerAction): action is ComposerCustomAction =>
349
372
  action.kind === "custom";
350
373
 
@@ -937,7 +960,10 @@ export function createComposerActionRenderer(
937
960
  let entry = entries.get(resolved.id);
938
961
  if (!entry) continue;
939
962
  const action = resolved.action;
940
- const visible = action ? evaluate(action.visible, state, true) : true;
963
+ const visible = action
964
+ ? evaluate(action.visible, state, true) &&
965
+ matchesComposerActionVisibility(action.visibility, state)
966
+ : true;
941
967
  if (entry.visible !== visible) {
942
968
  entry.visible = visible;
943
969
  // Managed elements leave the DOM when hidden; the node (and any custom
@@ -18,6 +18,7 @@ import { createPopover, type PopoverHandle } from "../plugin-kit";
18
18
  import type { ComposerCustomAction, ComposerModel } from "../types";
19
19
  import { createElement, createNode } from "../utils/dom";
20
20
  import { renderLucideIcon } from "../utils/icons";
21
+ import { forwardMenuTokens } from "./composer-parts";
21
22
 
22
23
  export const COMPOSER_MODEL_PICKER_ACTION_ID = "core:model";
23
24
  export const COMPOSER_MODEL_PICKER_ORDER = 700;
@@ -25,9 +26,8 @@ export const COMPOSER_MODEL_PICKER_ORDER = 700;
25
26
  const ROW_ICON_SIZE = 16;
26
27
 
27
28
  /**
28
- * Tokens the popover panel reads. It is portaled outside the themed mount, so
29
- * configured values are forwarded onto it on open; unset ones stamp nothing and
30
- * the stylesheet's fallbacks stand.
29
+ * Tokens the popover panel reads, forwarded onto the portaled panel on every
30
+ * open; unset ones clear so the stylesheet's fallbacks stand.
31
31
  */
32
32
  const MENU_TOKEN_VARS = [
33
33
  "--persona-components-composer-modelPicker-menuBackground",
@@ -62,16 +62,6 @@ export interface ComposerModelPicker {
62
62
  repaint: () => void;
63
63
  }
64
64
 
65
- const forwardMenuTokens = (from: HTMLElement, to: HTMLElement): void => {
66
- const view = from.ownerDocument?.defaultView;
67
- if (!view) return;
68
- const computed = view.getComputedStyle(from);
69
- for (const name of MENU_TOKEN_VARS) {
70
- const value = computed.getPropertyValue(name).trim();
71
- if (value) to.style.setProperty(name, value);
72
- }
73
- };
74
-
75
65
  /** The wrapper both presentations share: one grid cell, control plus chevron. */
76
66
  const createWrapper = (): { wrapper: HTMLElement; chevron: HTMLElement } => {
77
67
  // `appearance: none` drops the platform chevron, which Chrome draws
@@ -221,7 +211,7 @@ export function createComposerModelPickerAction(
221
211
  onDismiss: () => trigger.setAttribute("aria-expanded", "false"),
222
212
  });
223
213
  }
224
- forwardMenuTokens(trigger, menu);
214
+ forwardMenuTokens(trigger, menu, MENU_TOKEN_VARS);
225
215
  popover.open();
226
216
  trigger.setAttribute("aria-expanded", "true");
227
217
  const ids = [...rows.keys()];
@@ -19,6 +19,7 @@ import {
19
19
  COMPOSER_CONTROL_CLASS,
20
20
  COMPOSER_CONTROL_GLYPH_CLASS,
21
21
  COMPOSER_CONTROL_ICON_FALLBACK_PX,
22
+ forwardMenuTokens,
22
23
  } from "./composer-parts";
23
24
 
24
25
  export interface ComposerOverflowMenuOptions {
@@ -54,6 +55,18 @@ export interface ComposerOverflowMenu {
54
55
  const focusableSelector =
55
56
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
56
57
 
58
+ /**
59
+ * Panel surface tokens, forwarded onto the portaled panel on every open; unset
60
+ * ones clear so the stylesheet's fallbacks stand.
61
+ */
62
+ const MENU_TOKEN_VARS = [
63
+ "--persona-components-composer-overflowMenu-background",
64
+ "--persona-components-composer-overflowMenu-borderColor",
65
+ "--persona-components-composer-overflowMenu-borderRadius",
66
+ "--persona-components-composer-overflowMenu-foreground",
67
+ "--persona-components-composer-overflowMenu-shadow",
68
+ ] as const;
69
+
57
70
  /**
58
71
  * Set on the menu row that holds keyboard focus. Rows that shade on
59
72
  * `:focus-visible` do not need it; wrapper rows around a live control do.
@@ -249,6 +262,7 @@ export function createComposerOverflowMenu(
249
262
  const open = (focusIndex = 0, viaKeyboard = false): void => {
250
263
  if (destroyed || isOpen()) return;
251
264
  keyboardModality = viaKeyboard;
265
+ forwardMenuTokens(triggerButton, panel, MENU_TOKEN_VARS);
252
266
  ensurePopover().open();
253
267
  triggerButton.setAttribute("aria-expanded", "true");
254
268
  const targets = focusables();
@@ -41,6 +41,26 @@ export const COMPOSER_CONTROL_FALLBACK_PX = 40;
41
41
  /** Icon-size token default in CSS pixels, for the same JS-derived cases. */
42
42
  export const COMPOSER_CONTROL_ICON_FALLBACK_PX = 24;
43
43
 
44
+ /**
45
+ * Forward theme vars onto a portaled menu panel, which lives outside the
46
+ * themed mount. The panel is reused across opens, so an unset var must clear
47
+ * any previously stamped value for the stylesheet fallback to paint again.
48
+ */
49
+ export const forwardMenuTokens = (
50
+ from: HTMLElement,
51
+ to: HTMLElement,
52
+ names: readonly string[]
53
+ ): void => {
54
+ const view = from.ownerDocument?.defaultView;
55
+ if (!view) return;
56
+ const computed = view.getComputedStyle(from);
57
+ for (const name of names) {
58
+ const value = computed.getPropertyValue(name).trim();
59
+ if (value) to.style.setProperty(name, value);
60
+ else to.style.removeProperty(name);
61
+ }
62
+ };
63
+
44
64
  export interface ComposerTextareaParts {
45
65
  textarea: HTMLTextAreaElement;
46
66
  /**
@@ -58,8 +58,10 @@ export type RuntypeExecutionStreamEvent = ({
58
58
  message: string;
59
59
  };
60
60
  executionId: string;
61
+ finalOutput?: string;
61
62
  kind: "agent" | "flow";
62
63
  seq: number;
64
+ stopReason?: string;
63
65
  type: "execution_error";
64
66
  upgradeUrl?: string;
65
67
  }) | ({
package/src/index-core.ts CHANGED
@@ -91,6 +91,7 @@ export type {
91
91
  ComposerActionBase,
92
92
  ComposerActionPlacement,
93
93
  ComposerActionPresentation,
94
+ ComposerActionVisibility,
94
95
  ComposerButtonAction,
95
96
  ComposerCustomAction,
96
97
  ComposerActionContext,
@@ -103,6 +104,7 @@ export type {
103
104
  ComposerModelPickerPresentation,
104
105
  // Composer modes + mode groups (composer.modes, composer.modeGroups)
105
106
  ComposerMode,
107
+ ComposerModeChipVisibility,
106
108
  ComposerModeGroup,
107
109
  ComposerModeGroupPresentation,
108
110
  // Composer baseline configuration (submit keys, sizing, locks)