@runtypelabs/persona 3.15.1 → 3.17.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 (60) hide show
  1. package/dist/animations/glyph-cycle.cjs +279 -0
  2. package/dist/animations/glyph-cycle.d.cts +5 -0
  3. package/dist/animations/glyph-cycle.d.ts +5 -0
  4. package/dist/animations/glyph-cycle.js +252 -0
  5. package/dist/animations/types-HPZY7oAI.d.cts +282 -0
  6. package/dist/animations/types-HPZY7oAI.d.ts +282 -0
  7. package/dist/animations/wipe.cjs +107 -0
  8. package/dist/animations/wipe.d.cts +5 -0
  9. package/dist/animations/wipe.d.ts +5 -0
  10. package/dist/animations/wipe.js +80 -0
  11. package/dist/index.cjs +49 -48
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +216 -1
  14. package/dist/index.d.ts +216 -1
  15. package/dist/index.global.js +137 -82
  16. package/dist/index.global.js.map +1 -1
  17. package/dist/index.js +49 -48
  18. package/dist/index.js.map +1 -1
  19. package/dist/testing.cjs +85 -0
  20. package/dist/testing.d.cts +39 -0
  21. package/dist/testing.d.ts +39 -0
  22. package/dist/testing.js +56 -0
  23. package/dist/theme-editor.cjs +847 -127
  24. package/dist/theme-editor.d.cts +225 -2
  25. package/dist/theme-editor.d.ts +225 -2
  26. package/dist/theme-editor.js +845 -127
  27. package/dist/widget.css +133 -0
  28. package/package.json +20 -3
  29. package/src/animations/glyph-cycle.ts +332 -0
  30. package/src/animations/wipe.ts +66 -0
  31. package/src/client.test.ts +141 -0
  32. package/src/client.ts +197 -2
  33. package/src/components/composer-builder.ts +61 -10
  34. package/src/components/header-builder.ts +18 -7
  35. package/src/components/header-layouts.ts +3 -1
  36. package/src/components/message-bubble.test.ts +181 -2
  37. package/src/components/message-bubble.ts +209 -14
  38. package/src/components/panel.ts +4 -1
  39. package/src/defaults.ts +22 -0
  40. package/src/index-global.ts +31 -0
  41. package/src/index.ts +18 -0
  42. package/src/session.test.ts +93 -1
  43. package/src/session.ts +5 -0
  44. package/src/styles/widget.css +133 -0
  45. package/src/testing/index.ts +11 -0
  46. package/src/testing/mock-stream.test.ts +80 -0
  47. package/src/testing/mock-stream.ts +94 -0
  48. package/src/testing.ts +2 -0
  49. package/src/theme-editor/index.ts +4 -0
  50. package/src/theme-editor/preview-utils.test.ts +60 -0
  51. package/src/theme-editor/preview-utils.ts +129 -0
  52. package/src/theme-editor/sections.test.ts +19 -0
  53. package/src/theme-editor/sections.ts +84 -1
  54. package/src/types.ts +221 -0
  55. package/src/ui.stop-button.test.ts +165 -0
  56. package/src/ui.ts +79 -8
  57. package/src/utils/message-fingerprint.ts +2 -0
  58. package/src/utils/morph.ts +7 -0
  59. package/src/utils/stream-animation.test.ts +417 -0
  60. package/src/utils/stream-animation.ts +449 -0
@@ -2576,3 +2576,144 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
2576
2576
  });
2577
2577
  });
2578
2578
 
2579
+ // ============================================================================
2580
+ // stopReason wiring (agent_turn_complete / step_complete)
2581
+ // ============================================================================
2582
+
2583
+ describe('AgentWidgetClient - stopReason propagation', () => {
2584
+ const dispatchModeStream = (stopReason?: string) => {
2585
+ const data: Record<string, unknown> = {
2586
+ type: 'step_complete',
2587
+ id: 'step_1',
2588
+ stepType: 'prompt',
2589
+ result: { response: 'Hello there.' },
2590
+ };
2591
+ if (stopReason) data.stopReason = stopReason;
2592
+ return [
2593
+ `data: ${JSON.stringify(data)}\n\n`,
2594
+ `data: ${JSON.stringify({ type: 'flow_complete', success: true })}\n\n`,
2595
+ ];
2596
+ };
2597
+
2598
+ const collectFinalAssistant = (events: AgentWidgetEvent[]): AgentWidgetMessage | null => {
2599
+ const messageEvents = events.filter(e => e.type === 'message');
2600
+ for (let i = messageEvents.length - 1; i >= 0; i--) {
2601
+ const ev = messageEvents[i];
2602
+ if (ev.type === 'message' && ev.message.role === 'assistant' && !ev.message.streaming) {
2603
+ return ev.message;
2604
+ }
2605
+ }
2606
+ return null;
2607
+ };
2608
+
2609
+ const runDispatch = async (chunks: string[]): Promise<AgentWidgetEvent[]> => {
2610
+ global.fetch = vi.fn().mockImplementation(async (_url: string, _options: any) => {
2611
+ const encoder = new TextEncoder();
2612
+ const stream = new ReadableStream({
2613
+ start(controller) {
2614
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
2615
+ controller.close();
2616
+ }
2617
+ });
2618
+ return { ok: true, body: stream };
2619
+ });
2620
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2621
+ const events: AgentWidgetEvent[] = [];
2622
+ await client.dispatch(
2623
+ { messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
2624
+ (e) => events.push(e)
2625
+ );
2626
+ return events;
2627
+ };
2628
+
2629
+ it.each(['end_turn', 'max_tool_calls', 'length', 'content_filter', 'error', 'unknown'] as const)(
2630
+ 'attaches stopReason=%s from step_complete (dispatch / flow path)',
2631
+ async (stopReason) => {
2632
+ const events = await runDispatch(dispatchModeStream(stopReason));
2633
+ const final = collectFinalAssistant(events);
2634
+ expect(final).not.toBeNull();
2635
+ expect(final!.stopReason).toBe(stopReason);
2636
+ }
2637
+ );
2638
+
2639
+ it('leaves stopReason undefined when step_complete omits it (backcompat)', async () => {
2640
+ const events = await runDispatch(dispatchModeStream(undefined));
2641
+ const final = collectFinalAssistant(events);
2642
+ expect(final).not.toBeNull();
2643
+ expect(final!.stopReason).toBeUndefined();
2644
+ });
2645
+
2646
+ it('captures the empty-content + max_tool_calls regression case', async () => {
2647
+ // Symptom the upstream fix targets: model emits a tool call then gets cut
2648
+ // off before producing follow-up text. Persona must record stopReason so
2649
+ // the UI can render an affordance instead of an empty bubble.
2650
+ const events = await runDispatch([
2651
+ `data: ${JSON.stringify({
2652
+ type: 'step_complete',
2653
+ id: 'step_1',
2654
+ stepType: 'prompt',
2655
+ result: { response: '' },
2656
+ stopReason: 'max_tool_calls',
2657
+ })}\n\n`,
2658
+ `data: ${JSON.stringify({ type: 'flow_complete', success: true })}\n\n`,
2659
+ ]);
2660
+ const final = collectFinalAssistant(events);
2661
+ expect(final).not.toBeNull();
2662
+ expect(final!.content).toBe('');
2663
+ expect(final!.stopReason).toBe('max_tool_calls');
2664
+ });
2665
+
2666
+ it('agent_turn_complete.stopReason overrides any earlier step_complete value (agent-loop path)', async () => {
2667
+ // Build an agent-mode stream that emits both events. agent_turn_complete
2668
+ // arrives last; its stopReason should win.
2669
+ const execId = 'exec_stopreason';
2670
+ global.fetch = createAgentStreamFetch([
2671
+ sseEvent('agent_start', {
2672
+ executionId: execId, agentId: 'virtual', agentName: 'Test',
2673
+ maxTurns: 1, startedAt: new Date().toISOString(), seq: 1,
2674
+ }),
2675
+ sseEvent('agent_iteration_start', {
2676
+ executionId: execId, iteration: 1, maxTurns: 1,
2677
+ startedAt: new Date().toISOString(), seq: 2,
2678
+ }),
2679
+ sseEvent('agent_turn_start', {
2680
+ executionId: execId, iteration: 1, turnIndex: 0,
2681
+ role: 'assistant', turnId: 'turn_1', seq: 3,
2682
+ }),
2683
+ sseEvent('agent_turn_delta', {
2684
+ executionId: execId, iteration: 1, delta: 'partial answer',
2685
+ contentType: 'text', turnId: 'turn_1', seq: 4,
2686
+ }),
2687
+ sseEvent('agent_turn_complete', {
2688
+ executionId: execId, iteration: 1, role: 'assistant',
2689
+ turnId: 'turn_1', completedAt: new Date().toISOString(),
2690
+ stopReason: 'max_tool_calls', seq: 5,
2691
+ }),
2692
+ sseEvent('agent_iteration_complete', {
2693
+ executionId: execId, iteration: 1, toolCallsMade: 0,
2694
+ stopConditionMet: true, completedAt: new Date().toISOString(), seq: 6,
2695
+ }),
2696
+ sseEvent('agent_complete', {
2697
+ executionId: execId, agentId: 'virtual', success: true,
2698
+ iterations: 1, stopReason: 'max_iterations',
2699
+ completedAt: new Date().toISOString(), seq: 7,
2700
+ }),
2701
+ ]);
2702
+
2703
+ const client = new AgentWidgetClient({
2704
+ apiUrl: 'http://localhost:8000',
2705
+ agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
2706
+ });
2707
+ const events: AgentWidgetEvent[] = [];
2708
+ await client.dispatch(
2709
+ { messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
2710
+ (e) => events.push(e)
2711
+ );
2712
+
2713
+ const final = collectFinalAssistant(events);
2714
+ expect(final).not.toBeNull();
2715
+ expect(final!.stopReason).toBe('max_tool_calls');
2716
+ expect(final!.agentMetadata?.turnId).toBe('turn_1');
2717
+ });
2718
+ });
2719
+
package/src/client.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  AgentWidgetHeadersFunction,
14
14
  AgentWidgetSSEEventResult as _AgentWidgetSSEEventResult,
15
15
  AgentExecutionState,
16
+ StopReasonKind,
16
17
  ClientSession,
17
18
  ClientInitResponse,
18
19
  ClientChatRequest,
@@ -1057,6 +1058,16 @@ export class AgentWidgetClient {
1057
1058
  let didSplitByPartId = false;
1058
1059
  const reasoningMessages = new Map<string, AgentWidgetMessage>();
1059
1060
  const toolMessages = new Map<string, AgentWidgetMessage>();
1061
+ // Messages produced by steps inside a nested flow executed as a tool.
1062
+ // Keyed by `${parentToolId}::${nestedStepId}::${partId}` so each nested
1063
+ // step (send-stream, prompt) gets its own assistant message, and prompts
1064
+ // with inner tool calls split into one message per text segment — still
1065
+ // attributable to the parent tool call.
1066
+ const nestedStepMessages = new Map<string, AgentWidgetMessage>();
1067
+ // Most-recent partId seen for a given `${toolId}::${stepId}` scope, used
1068
+ // to seal the previous segment when a new partId arrives within the
1069
+ // same nested prompt step.
1070
+ const nestedPartIdByStep = new Map<string, string>();
1060
1071
  const reasoningContext = {
1061
1072
  lastId: null as string | null,
1062
1073
  byStep: new Map<string, string>()
@@ -1066,6 +1077,49 @@ export class AgentWidgetClient {
1066
1077
  byCall: new Map<string, string>()
1067
1078
  };
1068
1079
 
1080
+ // Nested message key. partId defaults to "" so steps without segmentation
1081
+ // (e.g. send-stream) still have a deterministic single key.
1082
+ const getNestedStepKey = (
1083
+ toolId: string,
1084
+ stepId: string,
1085
+ partId: string = ""
1086
+ ) => `${toolId}::${stepId}::${partId}`;
1087
+
1088
+ // Prefix used to sweep every nested message belonging to a single
1089
+ // (toolId, stepId) scope — needed on step_complete to seal any segments
1090
+ // that are still streaming.
1091
+ const getNestedStepPrefix = (toolId: string, stepId: string) =>
1092
+ `${toolId}::${stepId}::`;
1093
+
1094
+ const ensureNestedStepMessage = (
1095
+ toolId: string,
1096
+ stepId: string,
1097
+ partId: string,
1098
+ executionId?: string
1099
+ ): AgentWidgetMessage => {
1100
+ const key = getNestedStepKey(toolId, stepId, partId);
1101
+ const existing = nestedStepMessages.get(key);
1102
+ if (existing) return existing;
1103
+ const idSuffix = partId ? `-${partId}` : "";
1104
+ const message: AgentWidgetMessage = {
1105
+ id: `nested-${toolId}-${stepId}${idSuffix}`,
1106
+ role: "assistant",
1107
+ content: "",
1108
+ createdAt: new Date().toISOString(),
1109
+ streaming: true,
1110
+ sequence: nextSequence(),
1111
+ ...(partId ? { partId } : {}),
1112
+ agentMetadata: {
1113
+ executionId,
1114
+ parentToolId: toolId,
1115
+ parentStepId: stepId,
1116
+ },
1117
+ };
1118
+ nestedStepMessages.set(key, message);
1119
+ emitMessage(message);
1120
+ return message;
1121
+ };
1122
+
1069
1123
  const normalizeKey = (value: unknown): string | null => {
1070
1124
  if (value === null || value === undefined) return null;
1071
1125
  try {
@@ -1669,7 +1723,13 @@ export class AgentWidgetClient {
1669
1723
  toolContext.byCall.delete(callKey);
1670
1724
  }
1671
1725
  } else if (payloadType === "text_start") {
1672
- // Lifecycle event: a new text segment is beginning (emitted at tool boundaries)
1726
+ // Lifecycle event: a new text segment is beginning (emitted at tool boundaries).
1727
+ // When toolContext is present this fired inside a nested flow — it must not
1728
+ // seal or rotate the outer assistant message. Nested prompt segmentation is
1729
+ // handled via nestedStepMessages keyed by (toolId, stepId).
1730
+ if ((payload as any).toolContext?.toolId) {
1731
+ continue;
1732
+ }
1673
1733
  const incomingPartId = payload.partId;
1674
1734
  if (incomingPartId !== undefined && partIdState.current !== null && incomingPartId !== partIdState.current) {
1675
1735
  const prev = assistantMessage as AgentWidgetMessage | null;
@@ -1685,7 +1745,13 @@ export class AgentWidgetClient {
1685
1745
  partIdState.current = incomingPartId;
1686
1746
  }
1687
1747
  } else if (payloadType === "text_end") {
1688
- // Lifecycle event: current text segment ended (tool call about to start)
1748
+ // Lifecycle event: current text segment ended (tool call about to start).
1749
+ // When toolContext is present the boundary belongs to a nested flow — leave
1750
+ // outer assistant state alone so the outer stream is never interrupted by
1751
+ // nested activity.
1752
+ if ((payload as any).toolContext?.toolId) {
1753
+ continue;
1754
+ }
1689
1755
  // Seal the current assistant message so the next segment gets a new one
1690
1756
  const prev = assistantMessage as AgentWidgetMessage | null;
1691
1757
  if (prev) {
@@ -1704,6 +1770,77 @@ export class AgentWidgetClient {
1704
1770
  continue;
1705
1771
  }
1706
1772
 
1773
+ // Nested flow routing: when toolContext is present, this step_delta
1774
+ // originated inside a nested flow executed as a tool. Surface it as
1775
+ // its own assistant message keyed by the nested step id, so authors
1776
+ // who add send-stream / prompt steps inside their flow see them as
1777
+ // real messages in the timeline, in order — rather than merging
1778
+ // into the outer assistant bubble or getting buried in the tool
1779
+ // card. Each nested step id gets its own message; the parent tool
1780
+ // bubble continues to represent the invocation via tool_* events.
1781
+ const nestedToolCtx = (payload as any).toolContext as
1782
+ | { toolId?: string; stepId?: string; executionId?: string }
1783
+ | undefined;
1784
+ if (nestedToolCtx?.toolId) {
1785
+ const nestedStepId = String(
1786
+ payload.id ?? nestedToolCtx.stepId ?? `step-${nextSequence()}`
1787
+ );
1788
+ const incomingPartId =
1789
+ payload.partId !== undefined && payload.partId !== null
1790
+ ? String(payload.partId)
1791
+ : "";
1792
+ const stepScopeKey = `${nestedToolCtx.toolId}::${nestedStepId}`;
1793
+ const prevPartId = nestedPartIdByStep.get(stepScopeKey);
1794
+
1795
+ // If partId changed within this nested step (prompt with inner
1796
+ // tool call emitting a new text segment), seal the previous
1797
+ // segment's message so each segment renders as its own bubble.
1798
+ if (
1799
+ incomingPartId !== "" &&
1800
+ prevPartId !== undefined &&
1801
+ prevPartId !== "" &&
1802
+ prevPartId !== incomingPartId
1803
+ ) {
1804
+ const prev = nestedStepMessages.get(
1805
+ getNestedStepKey(
1806
+ nestedToolCtx.toolId,
1807
+ nestedStepId,
1808
+ prevPartId
1809
+ )
1810
+ );
1811
+ if (prev && prev.streaming !== false) {
1812
+ prev.streaming = false;
1813
+ emitMessage(prev);
1814
+ }
1815
+ }
1816
+ if (incomingPartId !== "") {
1817
+ nestedPartIdByStep.set(stepScopeKey, incomingPartId);
1818
+ }
1819
+
1820
+ const nestedMsg = ensureNestedStepMessage(
1821
+ nestedToolCtx.toolId,
1822
+ nestedStepId,
1823
+ incomingPartId,
1824
+ nestedToolCtx.executionId
1825
+ );
1826
+ const nestedChunk =
1827
+ payload.text ??
1828
+ payload.delta ??
1829
+ payload.content ??
1830
+ payload.chunk ??
1831
+ "";
1832
+ if (nestedChunk) {
1833
+ nestedMsg.content += String(nestedChunk);
1834
+ nestedMsg.streaming = true;
1835
+ emitMessage(nestedMsg);
1836
+ }
1837
+ if (payload.isComplete) {
1838
+ nestedMsg.streaming = false;
1839
+ emitMessage(nestedMsg);
1840
+ }
1841
+ continue;
1842
+ }
1843
+
1707
1844
  // partId-based segmentation: when partId changes, seal current message
1708
1845
  // and start a new one so text and tools render in chronological order
1709
1846
  const incomingPartId = payload.partId;
@@ -1927,11 +2064,51 @@ export class AgentWidgetClient {
1927
2064
  // Skip tool-related completions - they're handled by tool_complete
1928
2065
  continue;
1929
2066
  }
2067
+
2068
+ // Nested flow: seal every segment message produced by this nested
2069
+ // step (a single nested prompt step may have produced multiple
2070
+ // messages, one per partId, when inner tool calls split it). The
2071
+ // outer assistantMessage state is untouched so reconciliation for
2072
+ // the outer flow still works.
2073
+ const nestedCompleteCtx = (payload as any).toolContext as
2074
+ | { toolId?: string; stepId?: string; executionId?: string }
2075
+ | undefined;
2076
+ if (nestedCompleteCtx?.toolId) {
2077
+ const nestedStepId = String(
2078
+ payload.id ?? nestedCompleteCtx.stepId ?? ""
2079
+ );
2080
+ if (nestedStepId) {
2081
+ const prefix = getNestedStepPrefix(
2082
+ nestedCompleteCtx.toolId,
2083
+ nestedStepId
2084
+ );
2085
+ for (const [key, msg] of nestedStepMessages) {
2086
+ if (key.startsWith(prefix) && msg.streaming !== false) {
2087
+ msg.streaming = false;
2088
+ emitMessage(msg);
2089
+ }
2090
+ }
2091
+ nestedPartIdByStep.delete(
2092
+ `${nestedCompleteCtx.toolId}::${nestedStepId}`
2093
+ );
2094
+ }
2095
+ continue;
2096
+ }
2097
+
2098
+ // Capture optional per-step stopReason emitted by the runtime
2099
+ // (e.g. `'max_tool_calls'`, `'length'`). This is the dispatch-mode
2100
+ // fallback — `agent_turn_complete` will overwrite it later in
2101
+ // agent-loop streams.
2102
+ const stepStopReason = (payload as any).stopReason as
2103
+ | StopReasonKind
2104
+ | undefined;
2105
+
1930
2106
  if (didSplitByPartId) {
1931
2107
  // Sealed segment(s) — do not create a second bubble from step_complete.
1932
2108
  // Merge authoritative final response into the last sealed segment (fixes async lag).
1933
2109
  if (assistantMessage !== null) {
1934
2110
  const msg: AgentWidgetMessage = assistantMessage;
2111
+ if (stepStopReason) msg.stopReason = stepStopReason;
1935
2112
  streamParsers.delete(msg.id);
1936
2113
  rawContentBuffers.delete(msg.id);
1937
2114
  if (msg.streaming !== false) {
@@ -1942,6 +2119,7 @@ export class AgentWidgetClient {
1942
2119
  const splitFinalContent = payload.result?.response;
1943
2120
  const sealedForReconcile = lastSealedTextSegment;
1944
2121
  if (sealedForReconcile) {
2122
+ if (stepStopReason) sealedForReconcile.stopReason = stepStopReason;
1945
2123
  if (splitFinalContent !== undefined && splitFinalContent !== null) {
1946
2124
  reconcileSealedAssistantWithFinalResponse(sealedForReconcile, splitFinalContent);
1947
2125
  } else {
@@ -1954,6 +2132,7 @@ export class AgentWidgetClient {
1954
2132
  }
1955
2133
  const finalContent = payload.result?.response;
1956
2134
  const assistant = ensureAssistantMessage();
2135
+ if (stepStopReason) assistant.stopReason = stepStopReason;
1957
2136
  if (finalContent !== undefined && finalContent !== null) {
1958
2137
  // Check if we already have extracted text from streaming
1959
2138
  const parser = streamParsers.get(assistant.id);
@@ -2241,6 +2420,22 @@ export class AgentWidgetClient {
2241
2420
  emitMessage(reasoningMessage);
2242
2421
  }
2243
2422
  }
2423
+
2424
+ // Attach the turn-level stopReason to the assistant message
2425
+ // produced by this turn. Only overwrite the current message —
2426
+ // prior turns already sealed their own stopReason via step_complete.
2427
+ const turnStopReason = (payload as any).stopReason as
2428
+ | StopReasonKind
2429
+ | undefined;
2430
+ if (turnStopReason && assistantMessage !== null) {
2431
+ const turnId = payload.turnId;
2432
+ const matchesTurn =
2433
+ !turnId || assistantMessage.agentMetadata?.turnId === turnId;
2434
+ if (matchesTurn) {
2435
+ assistantMessage.stopReason = turnStopReason;
2436
+ emitMessage(assistantMessage);
2437
+ }
2438
+ }
2244
2439
  } else if (payloadType === "agent_tool_start") {
2245
2440
  const toolId = payload.toolCallId ?? `agent-tool-${nextSequence()}`;
2246
2441
  trackToolId(getToolCallKey(payload), toolId);
@@ -22,6 +22,13 @@ export interface ComposerElements {
22
22
  actionsRow: HTMLElement;
23
23
  leftActions: HTMLElement;
24
24
  rightActions: HTMLElement;
25
+ /**
26
+ * Swap the send button between its idle ("send") appearance and its
27
+ * streaming ("stop") appearance. In icon mode this swaps the SVG; in text
28
+ * mode it swaps the button label. Tooltip text is updated when a tooltip
29
+ * element is present.
30
+ */
31
+ setSendButtonMode: (mode: "send" | "stop") => void;
25
32
  }
26
33
 
27
34
  /**
@@ -122,7 +129,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
122
129
  const useIcon = sendButtonConfig.useIcon ?? false;
123
130
  const iconText = sendButtonConfig.iconText ?? "↑";
124
131
  const iconName = sendButtonConfig.iconName;
132
+ const stopIconName = sendButtonConfig.stopIconName ?? "square";
125
133
  const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
134
+ const stopTooltipText = sendButtonConfig.stopTooltipText ?? "Stop generating";
135
+ const sendLabel = config?.copy?.sendButtonLabel ?? "Send";
136
+ const stopLabel = config?.copy?.stopButtonLabel ?? "Stop";
126
137
  const showTooltip = sendButtonConfig.showTooltip ?? false;
127
138
  const buttonSize = sendButtonConfig.size ?? "40px";
128
139
  const backgroundColor = sendButtonConfig.backgroundColor;
@@ -141,6 +152,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
141
152
  sendButton.type = "submit";
142
153
  sendButton.setAttribute("data-persona-composer-submit", "");
143
154
 
155
+ // Icons for both modes are pre-rendered so setSendButtonMode can swap them
156
+ // without having to re-render on every streaming state change.
157
+ let sendIcon: SVGElement | null = null;
158
+ let stopIcon: SVGElement | null = null;
159
+
144
160
  if (useIcon) {
145
161
  // Icon mode: circular button
146
162
  sendButton.style.width = buttonSize;
@@ -160,13 +176,14 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
160
176
  sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
161
177
  }
162
178
 
179
+ const iconSize = parseFloat(buttonSize) || 24;
180
+ const iconColor = textColor?.trim() || "currentColor";
181
+
163
182
  // Use Lucide icon if iconName is provided, otherwise fall back to iconText
164
183
  if (iconName) {
165
- const iconSize = parseFloat(buttonSize) || 24;
166
- const iconColor = textColor?.trim() || "currentColor";
167
- const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
168
- if (iconSvg) {
169
- sendButton.appendChild(iconSvg);
184
+ sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
185
+ if (sendIcon) {
186
+ sendButton.appendChild(sendIcon);
170
187
  } else {
171
188
  sendButton.textContent = iconText;
172
189
  }
@@ -174,6 +191,9 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
174
191
  sendButton.textContent = iconText;
175
192
  }
176
193
 
194
+ // Pre-render the stop icon so mode swaps are cheap; it starts detached.
195
+ stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
196
+
177
197
  if (backgroundColor) {
178
198
  sendButton.style.backgroundColor = backgroundColor;
179
199
  } else {
@@ -181,7 +201,7 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
181
201
  }
182
202
  } else {
183
203
  // Text mode: existing behavior
184
- sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
204
+ sendButton.textContent = sendLabel;
185
205
  if (textColor) {
186
206
  sendButton.style.color = textColor;
187
207
  } else {
@@ -215,14 +235,44 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
215
235
  }
216
236
 
217
237
  // Add tooltip if enabled
238
+ let sendTooltip: HTMLElement | null = null;
218
239
  if (showTooltip && tooltipText) {
219
- const tooltip = createElement("div", "persona-send-button-tooltip");
220
- tooltip.textContent = tooltipText;
221
- sendButtonWrapper.appendChild(tooltip);
240
+ sendTooltip = createElement("div", "persona-send-button-tooltip");
241
+ sendTooltip.textContent = tooltipText;
242
+ sendButtonWrapper.appendChild(sendTooltip);
222
243
  }
223
244
 
245
+ sendButton.setAttribute("aria-label", tooltipText);
246
+
224
247
  sendButtonWrapper.appendChild(sendButton);
225
248
 
249
+ let currentMode: "send" | "stop" = "send";
250
+ const setSendButtonMode = (mode: "send" | "stop") => {
251
+ if (mode === currentMode) return;
252
+ currentMode = mode;
253
+ const label = mode === "stop" ? stopTooltipText : tooltipText;
254
+ sendButton.setAttribute("aria-label", label);
255
+ if (sendTooltip) {
256
+ sendTooltip.textContent = label;
257
+ }
258
+
259
+ if (useIcon) {
260
+ // Only swap icons if both were rendered successfully; otherwise the
261
+ // button is using textContent fallback and there's nothing to swap.
262
+ if (sendIcon && stopIcon) {
263
+ const next = mode === "stop" ? stopIcon : sendIcon;
264
+ const prev = mode === "stop" ? sendIcon : stopIcon;
265
+ if (prev.parentNode === sendButton) {
266
+ sendButton.replaceChild(next, prev);
267
+ } else {
268
+ sendButton.appendChild(next);
269
+ }
270
+ }
271
+ } else {
272
+ sendButton.textContent = mode === "stop" ? stopLabel : sendLabel;
273
+ }
274
+ };
275
+
226
276
  // Voice recognition mic button
227
277
  const voiceRecognitionConfig = config?.voiceRecognition ?? {};
228
278
  const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
@@ -515,7 +565,8 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
515
565
  // Actions row layout elements
516
566
  actionsRow,
517
567
  leftActions,
518
- rightActions
568
+ rightActions,
569
+ setSendButtonMode
519
570
  };
520
571
  };
521
572
 
@@ -159,9 +159,11 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
159
159
  clearChatButton.style.color =
160
160
  clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
161
161
 
162
- // Add icon
162
+ // Add icon. display:block eliminates inline-baseline spacing that can
163
+ // push the icon a fractional pixel off-center inside the button.
163
164
  const iconSvg = renderLucideIcon(clearChatIconName, "20px", "currentColor", 1);
164
165
  if (iconSvg) {
166
+ iconSvg.style.display = "block";
165
167
  clearChatButton.appendChild(iconSvg);
166
168
  }
167
169
 
@@ -276,15 +278,17 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
276
278
  }
277
279
  }
278
280
 
279
- // Create close button wrapper for tooltip positioning
280
- // Only needs ml-auto if clear chat is disabled or top-right positioned
281
+ // Create close button wrapper for tooltip positioning.
282
+ // Mirrors the clear-chat wrapper's inline-flex centering so both
283
+ // header action buttons vertically align identically within the
284
+ // header's flex row.
281
285
  const closeButtonWrapper = createElement(
282
286
  "div",
283
287
  closeButtonPlacement === "top-right"
284
288
  ? "persona-absolute persona-top-4 persona-right-4 persona-z-50"
285
289
  : clearChatEnabled && clearChatPlacement === "inline"
286
- ? ""
287
- : "persona-ml-auto"
290
+ ? "persona-relative persona-inline-flex persona-items-center persona-justify-center"
291
+ : "persona-relative persona-ml-auto persona-inline-flex persona-items-center persona-justify-center"
288
292
  );
289
293
 
290
294
  // Create close button with base classes
@@ -309,9 +313,16 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
309
313
  closeButton.style.color =
310
314
  launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
311
315
 
312
- // Try to render Lucide icon, fallback to text if not provided or fails
313
- const closeIconSvg = renderLucideIcon(closeButtonIconName, "20px", "currentColor", 1);
316
+ // Try to render Lucide icon, fallback to text if not provided or fails.
317
+ // The X glyph's paths occupy only the middle 50% of its 24x24 viewBox
318
+ // (from 6,6 to 18,18), while other header icons (e.g. refresh-cw) span
319
+ // ~75% of the viewBox. Rendering X at a larger intrinsic size brings
320
+ // its visible extent into parity with sibling icons in the header.
321
+ // display:block eliminates inline-baseline spacing that can push the
322
+ // icon a fractional pixel off-center inside the button.
323
+ const closeIconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
314
324
  if (closeIconSvg) {
325
+ closeIconSvg.style.display = "block";
315
326
  closeButton.appendChild(closeIconSvg);
316
327
  } else {
317
328
  closeButton.textContent = closeButtonIconText;
@@ -215,7 +215,9 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
215
215
  launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
216
216
 
217
217
  const closeButtonIconName = launcher.closeButtonIconName ?? "x";
218
- const closeIconSvg = renderLucideIcon(closeButtonIconName, "20px", "currentColor", 2);
218
+ // Larger intrinsic size compensates for the X glyph's sparse viewBox
219
+ // (paths only occupy the middle 50%). Matches header-builder.ts.
220
+ const closeIconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
219
221
  if (closeIconSvg) {
220
222
  closeButton.appendChild(closeIconSvg);
221
223
  } else {