@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.
- package/dist/animations/glyph-cycle.cjs +279 -0
- package/dist/animations/glyph-cycle.d.cts +5 -0
- package/dist/animations/glyph-cycle.d.ts +5 -0
- package/dist/animations/glyph-cycle.js +252 -0
- package/dist/animations/types-HPZY7oAI.d.cts +282 -0
- package/dist/animations/types-HPZY7oAI.d.ts +282 -0
- package/dist/animations/wipe.cjs +107 -0
- package/dist/animations/wipe.d.cts +5 -0
- package/dist/animations/wipe.d.ts +5 -0
- package/dist/animations/wipe.js +80 -0
- package/dist/index.cjs +49 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -1
- package/dist/index.d.ts +216 -1
- package/dist/index.global.js +137 -82
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -48
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +85 -0
- package/dist/testing.d.cts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +56 -0
- package/dist/theme-editor.cjs +847 -127
- package/dist/theme-editor.d.cts +225 -2
- package/dist/theme-editor.d.ts +225 -2
- package/dist/theme-editor.js +845 -127
- package/dist/widget.css +133 -0
- package/package.json +20 -3
- package/src/animations/glyph-cycle.ts +332 -0
- package/src/animations/wipe.ts +66 -0
- package/src/client.test.ts +141 -0
- package/src/client.ts +197 -2
- package/src/components/composer-builder.ts +61 -10
- package/src/components/header-builder.ts +18 -7
- package/src/components/header-layouts.ts +3 -1
- package/src/components/message-bubble.test.ts +181 -2
- package/src/components/message-bubble.ts +209 -14
- package/src/components/panel.ts +4 -1
- package/src/defaults.ts +22 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +18 -0
- package/src/session.test.ts +93 -1
- package/src/session.ts +5 -0
- package/src/styles/widget.css +133 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-stream.test.ts +80 -0
- package/src/testing/mock-stream.ts +94 -0
- package/src/testing.ts +2 -0
- package/src/theme-editor/index.ts +4 -0
- package/src/theme-editor/preview-utils.test.ts +60 -0
- package/src/theme-editor/preview-utils.ts +129 -0
- package/src/theme-editor/sections.test.ts +19 -0
- package/src/theme-editor/sections.ts +84 -1
- package/src/types.ts +221 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +79 -8
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
package/src/client.test.ts
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 =
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
sendButtonWrapper.appendChild(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|