@poncho-ai/cli 0.7.1 → 0.8.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 (47) hide show
  1. package/.turbo/turbo-build.log +5 -5
  2. package/dist/chunk-3QVOY7B6.js +5715 -0
  3. package/dist/chunk-ANOPJ4X2.js +5642 -0
  4. package/dist/chunk-CC667GH3.js +5730 -0
  5. package/dist/chunk-CH4XYHJE.js +5727 -0
  6. package/dist/chunk-CP6UFUK2.js +5724 -0
  7. package/dist/chunk-EZLJV7OP.js +5710 -0
  8. package/dist/chunk-KR5CYUSD.js +5709 -0
  9. package/dist/chunk-LSOIOT57.js +5715 -0
  10. package/dist/chunk-MVYF325X.js +5695 -0
  11. package/dist/chunk-N6NRICKA.js +5669 -0
  12. package/dist/chunk-PPPOPLC5.js +5683 -0
  13. package/dist/chunk-QXF4F3KZ.js +5725 -0
  14. package/dist/chunk-RJTGWTAI.js +5648 -0
  15. package/dist/chunk-RYXYGUDN.js +5710 -0
  16. package/dist/chunk-TDPERUEG.js +5669 -0
  17. package/dist/chunk-XPDWQPS2.js +5724 -0
  18. package/dist/chunk-YSDDBHRF.js +5705 -0
  19. package/dist/chunk-ZKK2TINV.js +5709 -0
  20. package/dist/chunk-ZL7H73NW.js +5458 -0
  21. package/dist/chunk-ZMR6K3AO.js +5730 -0
  22. package/dist/cli.js +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/run-interactive-ink-35CB7WDL.js +494 -0
  25. package/dist/run-interactive-ink-4ANAOBOL.js +517 -0
  26. package/dist/run-interactive-ink-4KWEORVB.js +517 -0
  27. package/dist/run-interactive-ink-4PXAYUJG.js +517 -0
  28. package/dist/run-interactive-ink-7SLXGC72.js +517 -0
  29. package/dist/run-interactive-ink-C7TDMV7Z.js +517 -0
  30. package/dist/run-interactive-ink-IP53WQ3O.js +517 -0
  31. package/dist/run-interactive-ink-KLIGYEVZ.js +520 -0
  32. package/dist/run-interactive-ink-LNAAWJDA.js +517 -0
  33. package/dist/run-interactive-ink-OQUOMIO3.js +520 -0
  34. package/dist/run-interactive-ink-RDRKOXZ5.js +517 -0
  35. package/dist/run-interactive-ink-RDSD5STV.js +517 -0
  36. package/dist/run-interactive-ink-RO6MARC3.js +517 -0
  37. package/dist/run-interactive-ink-SVHTURD5.js +517 -0
  38. package/dist/run-interactive-ink-U4JKAUVO.js +517 -0
  39. package/dist/run-interactive-ink-UL5MFLAE.js +517 -0
  40. package/dist/run-interactive-ink-VIE5B4YO.js +517 -0
  41. package/dist/run-interactive-ink-WTEVB7TK.js +517 -0
  42. package/dist/run-interactive-ink-YZM7ZTJJ.js +517 -0
  43. package/dist/run-interactive-ink-YZRP7BPI.js +525 -0
  44. package/package.json +3 -3
  45. package/src/index.ts +160 -15
  46. package/src/run-interactive-ink.ts +45 -18
  47. package/src/web-ui.ts +130 -16
package/src/index.ts CHANGED
@@ -307,6 +307,11 @@ poncho dev
307
307
  Open \`http://localhost:3000\` for the web UI.
308
308
 
309
309
  On your first interactive session, the agent introduces its configurable capabilities.
310
+ While a response is streaming, you can stop it:
311
+ - Web UI: click the send button again (it switches to a stop icon)
312
+ - Interactive CLI: press \`Ctrl+C\`
313
+
314
+ Stopping is best-effort and keeps partial assistant output/tool activity already produced.
310
315
 
311
316
  ## Common Commands
312
317
 
@@ -857,6 +862,12 @@ export const createRequestHandler = async (options?: {
857
862
  } catch {}
858
863
  const runOwners = new Map<string, string>();
859
864
  const runConversations = new Map<string, string>();
865
+ type ActiveConversationRun = {
866
+ ownerId: string;
867
+ abortController: AbortController;
868
+ runId: string | null;
869
+ };
870
+ const activeConversationRuns = new Map<string, ActiveConversationRun>();
860
871
  type PendingApproval = {
861
872
  ownerId: string;
862
873
  runId: string;
@@ -924,6 +935,16 @@ export const createRequestHandler = async (options?: {
924
935
  }));
925
936
  await conversationStore.update(conversation);
926
937
  };
938
+ const clearPendingApprovalsForConversation = async (conversationId: string): Promise<void> => {
939
+ for (const [approvalId, pending] of pendingApprovals.entries()) {
940
+ if (pending.conversationId !== conversationId) {
941
+ continue;
942
+ }
943
+ pendingApprovals.delete(approvalId);
944
+ pending.resolve(false);
945
+ }
946
+ await persistConversationPendingApprovals(conversationId);
947
+ };
927
948
  const harness = new AgentHarness({
928
949
  workingDir,
929
950
  environment: resolveHarnessEnvironment(),
@@ -1319,6 +1340,61 @@ export const createRequestHandler = async (options?: {
1319
1340
  }
1320
1341
  }
1321
1342
 
1343
+ const conversationStopMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/stop$/);
1344
+ if (conversationStopMatch && request.method === "POST") {
1345
+ const conversationId = decodeURIComponent(conversationStopMatch[1] ?? "");
1346
+ const body = (await readRequestBody(request)) as { runId?: string };
1347
+ const requestedRunId = typeof body.runId === "string" ? body.runId.trim() : "";
1348
+ const conversation = await conversationStore.get(conversationId);
1349
+ if (!conversation || conversation.ownerId !== ownerId) {
1350
+ writeJson(response, 404, {
1351
+ code: "CONVERSATION_NOT_FOUND",
1352
+ message: "Conversation not found",
1353
+ });
1354
+ return;
1355
+ }
1356
+ const activeRun = activeConversationRuns.get(conversationId);
1357
+ if (!activeRun || activeRun.ownerId !== ownerId) {
1358
+ writeJson(response, 200, {
1359
+ ok: true,
1360
+ stopped: false,
1361
+ });
1362
+ return;
1363
+ }
1364
+ if (activeRun.abortController.signal.aborted) {
1365
+ activeConversationRuns.delete(conversationId);
1366
+ writeJson(response, 200, {
1367
+ ok: true,
1368
+ stopped: false,
1369
+ });
1370
+ return;
1371
+ }
1372
+ if (requestedRunId && activeRun.runId !== requestedRunId) {
1373
+ writeJson(response, 200, {
1374
+ ok: true,
1375
+ stopped: false,
1376
+ runId: activeRun.runId ?? undefined,
1377
+ });
1378
+ return;
1379
+ }
1380
+ if (!requestedRunId) {
1381
+ writeJson(response, 200, {
1382
+ ok: true,
1383
+ stopped: false,
1384
+ runId: activeRun.runId ?? undefined,
1385
+ });
1386
+ return;
1387
+ }
1388
+ activeRun.abortController.abort();
1389
+ await clearPendingApprovalsForConversation(conversationId);
1390
+ writeJson(response, 200, {
1391
+ ok: true,
1392
+ stopped: true,
1393
+ runId: activeRun.runId ?? undefined,
1394
+ });
1395
+ return;
1396
+ }
1397
+
1322
1398
  const conversationMessageMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/messages$/);
1323
1399
  if (conversationMessageMatch && request.method === "POST") {
1324
1400
  const conversationId = decodeURIComponent(conversationMessageMatch[1] ?? "");
@@ -1342,6 +1418,24 @@ export const createRequestHandler = async (options?: {
1342
1418
  });
1343
1419
  return;
1344
1420
  }
1421
+ const activeRun = activeConversationRuns.get(conversationId);
1422
+ if (activeRun && activeRun.ownerId === ownerId) {
1423
+ if (activeRun.abortController.signal.aborted) {
1424
+ activeConversationRuns.delete(conversationId);
1425
+ } else {
1426
+ writeJson(response, 409, {
1427
+ code: "RUN_IN_PROGRESS",
1428
+ message: "A run is already active for this conversation",
1429
+ });
1430
+ return;
1431
+ }
1432
+ }
1433
+ const abortController = new AbortController();
1434
+ activeConversationRuns.set(conversationId, {
1435
+ ownerId,
1436
+ abortController,
1437
+ runId: null,
1438
+ });
1345
1439
  if (
1346
1440
  conversation.messages.length === 0 &&
1347
1441
  (conversation.title === "New conversation" || conversation.title.trim().length === 0)
@@ -1360,6 +1454,7 @@ export const createRequestHandler = async (options?: {
1360
1454
  const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
1361
1455
  let currentText = "";
1362
1456
  let currentTools: string[] = [];
1457
+ let runCancelled = false;
1363
1458
  try {
1364
1459
  // Persist the user turn immediately so refreshing mid-run keeps chat context.
1365
1460
  conversation.messages = [...historyMessages, { role: "user", content: messageText }];
@@ -1426,11 +1521,19 @@ export const createRequestHandler = async (options?: {
1426
1521
  __activeConversationId: conversationId,
1427
1522
  },
1428
1523
  messages: historyMessages,
1524
+ abortSignal: abortController.signal,
1429
1525
  })) {
1430
1526
  if (event.type === "run:started") {
1431
1527
  latestRunId = event.runId;
1432
1528
  runOwners.set(event.runId, ownerId);
1433
1529
  runConversations.set(event.runId, conversationId);
1530
+ const active = activeConversationRuns.get(conversationId);
1531
+ if (active && active.abortController === abortController) {
1532
+ active.runId = event.runId;
1533
+ }
1534
+ }
1535
+ if (event.type === "run:cancelled") {
1536
+ runCancelled = true;
1434
1537
  }
1435
1538
  if (event.type === "model:chunk") {
1436
1539
  // If we have tools accumulated and text starts again, push tools as a section
@@ -1502,26 +1605,60 @@ export const createRequestHandler = async (options?: {
1502
1605
  if (currentText.length > 0) {
1503
1606
  sections.push({ type: "text", content: currentText });
1504
1607
  }
1505
- conversation.messages = [
1506
- ...historyMessages,
1507
- { role: "user", content: messageText },
1508
- {
1509
- role: "assistant",
1510
- content: assistantResponse,
1511
- metadata:
1512
- toolTimeline.length > 0 || sections.length > 0
1513
- ? ({
1514
- toolActivity: toolTimeline,
1515
- sections: sections.length > 0 ? sections : undefined,
1516
- } as Message["metadata"])
1517
- : undefined,
1518
- },
1519
- ];
1608
+ const hasAssistantContent =
1609
+ assistantResponse.length > 0 || toolTimeline.length > 0 || sections.length > 0;
1610
+ conversation.messages = hasAssistantContent
1611
+ ? [
1612
+ ...historyMessages,
1613
+ { role: "user", content: messageText },
1614
+ {
1615
+ role: "assistant",
1616
+ content: assistantResponse,
1617
+ metadata:
1618
+ toolTimeline.length > 0 || sections.length > 0
1619
+ ? ({
1620
+ toolActivity: toolTimeline,
1621
+ sections: sections.length > 0 ? sections : undefined,
1622
+ } as Message["metadata"])
1623
+ : undefined,
1624
+ },
1625
+ ]
1626
+ : [...historyMessages, { role: "user", content: messageText }];
1520
1627
  conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
1521
1628
  conversation.pendingApprovals = [];
1522
1629
  conversation.updatedAt = Date.now();
1523
1630
  await conversationStore.update(conversation);
1524
1631
  } catch (error) {
1632
+ if (abortController.signal.aborted || runCancelled) {
1633
+ const fallbackSections = [...sections];
1634
+ if (currentTools.length > 0) {
1635
+ fallbackSections.push({ type: "tools", content: [...currentTools] });
1636
+ }
1637
+ if (currentText.length > 0) {
1638
+ fallbackSections.push({ type: "text", content: currentText });
1639
+ }
1640
+ if (assistantResponse.length > 0 || toolTimeline.length > 0 || fallbackSections.length > 0) {
1641
+ conversation.messages = [
1642
+ ...historyMessages,
1643
+ { role: "user", content: messageText },
1644
+ {
1645
+ role: "assistant",
1646
+ content: assistantResponse,
1647
+ metadata:
1648
+ toolTimeline.length > 0 || fallbackSections.length > 0
1649
+ ? ({
1650
+ toolActivity: [...toolTimeline],
1651
+ sections: fallbackSections.length > 0 ? fallbackSections : undefined,
1652
+ } as Message["metadata"])
1653
+ : undefined,
1654
+ },
1655
+ ];
1656
+ conversation.updatedAt = Date.now();
1657
+ await conversationStore.update(conversation);
1658
+ }
1659
+ await clearPendingApprovalsForConversation(conversationId);
1660
+ return;
1661
+ }
1525
1662
  try {
1526
1663
  response.write(
1527
1664
  formatSseEvent({
@@ -1563,6 +1700,10 @@ export const createRequestHandler = async (options?: {
1563
1700
  }
1564
1701
  }
1565
1702
  } finally {
1703
+ const active = activeConversationRuns.get(conversationId);
1704
+ if (active && active.abortController === abortController) {
1705
+ activeConversationRuns.delete(conversationId);
1706
+ }
1566
1707
  finishConversationStream(conversationId);
1567
1708
  await persistConversationPendingApprovals(conversationId);
1568
1709
  if (latestRunId) {
@@ -1654,6 +1795,10 @@ export const runOnce = async (
1654
1795
  if (event.type === "run:completed") {
1655
1796
  process.stdout.write("\n");
1656
1797
  }
1798
+ if (event.type === "run:cancelled") {
1799
+ process.stdout.write("\n");
1800
+ process.stderr.write("Run cancelled.\n");
1801
+ }
1657
1802
  }
1658
1803
  };
1659
1804
 
@@ -355,6 +355,7 @@ export const runInteractiveInk = async ({
355
355
  ),
356
356
  );
357
357
  console.log(gray('Type "exit" to quit, "/help" for commands'));
358
+ console.log(gray("Press Ctrl+C during a run to stop streaming output."));
358
359
  console.log(
359
360
  gray("Conversation controls: /list /open <id> /new [title] /delete [id] /continue /reset [all]\n"),
360
361
  );
@@ -376,6 +377,17 @@ export const runInteractiveInk = async ({
376
377
  let turn = 1;
377
378
  let activeConversationId: string | null = null;
378
379
  let showToolPayloads = false;
380
+ let activeRunAbortController: AbortController | null = null;
381
+
382
+ rl.on("SIGINT", () => {
383
+ if (activeRunAbortController && !activeRunAbortController.signal.aborted) {
384
+ activeRunAbortController.abort();
385
+ process.stdout.write("\n");
386
+ console.log(gray("stop> cancelling current run..."));
387
+ return;
388
+ }
389
+ rl.close();
390
+ });
379
391
 
380
392
  // --- Main loop -------------------------------------------------------------
381
393
 
@@ -447,15 +459,18 @@ export const runInteractiveInk = async ({
447
459
  let currentText = "";
448
460
  let currentTools: string[] = [];
449
461
  let runFailed = false;
462
+ let runCancelled = false;
450
463
  let usage: TokenUsage | undefined;
451
464
  let latestRunId = "";
452
465
  const startedAt = Date.now();
466
+ activeRunAbortController = new AbortController();
453
467
 
454
468
  try {
455
469
  for await (const event of harness.run({
456
470
  task: trimmed,
457
471
  parameters: params,
458
472
  messages,
473
+ abortSignal: activeRunAbortController.signal,
459
474
  })) {
460
475
  if (event.type === "run:started") {
461
476
  latestRunId = event.runId;
@@ -555,6 +570,9 @@ export const runInteractiveInk = async ({
555
570
  clearThinking();
556
571
  runFailed = true;
557
572
  console.log(red(`error> ${event.error.message}`));
573
+ } else if (event.type === "run:cancelled") {
574
+ clearThinking();
575
+ runCancelled = true;
558
576
  } else if (event.type === "model:response") {
559
577
  usage = event.usage;
560
578
  } else if (event.type === "run:completed" && !sawChunk) {
@@ -569,18 +587,24 @@ export const runInteractiveInk = async ({
569
587
  }
570
588
  } catch (error) {
571
589
  clearThinking();
572
- runFailed = true;
573
- console.log(
574
- red(
575
- `error> ${error instanceof Error ? error.message : "Unknown error"}`,
576
- ),
577
- );
590
+ if (activeRunAbortController.signal.aborted) {
591
+ runCancelled = true;
592
+ } else {
593
+ runFailed = true;
594
+ console.log(
595
+ red(
596
+ `error> ${error instanceof Error ? error.message : "Unknown error"}`,
597
+ ),
598
+ );
599
+ }
600
+ } finally {
601
+ activeRunAbortController = null;
578
602
  }
579
603
 
580
604
  // End the streaming line if needed
581
605
  if (sawChunk && streamedText.length > 0) {
582
606
  process.stdout.write("\n");
583
- } else if (!sawChunk && !runFailed && responseText.length === 0) {
607
+ } else if (!sawChunk && !runFailed && !runCancelled && responseText.length === 0) {
584
608
  clearThinking();
585
609
  console.log(green("assistant> (no response)"));
586
610
  }
@@ -621,17 +645,20 @@ export const runInteractiveInk = async ({
621
645
  }
622
646
 
623
647
  messages.push({ role: "user", content: trimmed });
624
- messages.push({
625
- role: "assistant",
626
- content: responseText,
627
- metadata:
628
- toolTimeline.length > 0 || sections.length > 0
629
- ? ({
630
- toolActivity: toolTimeline,
631
- sections: sections.length > 0 ? sections : undefined,
632
- } as Message["metadata"])
633
- : undefined,
634
- });
648
+ const hasAssistantContent = responseText.length > 0 || toolTimeline.length > 0 || sections.length > 0;
649
+ if (hasAssistantContent) {
650
+ messages.push({
651
+ role: "assistant",
652
+ content: responseText,
653
+ metadata:
654
+ toolTimeline.length > 0 || sections.length > 0
655
+ ? ({
656
+ toolActivity: toolTimeline,
657
+ sections: sections.length > 0 ? sections : undefined,
658
+ } as Message["metadata"])
659
+ : undefined,
660
+ });
661
+ }
635
662
  turn = computeTurn(messages);
636
663
 
637
664
  const conversation = await conversationStore.get(activeConversationId);
package/src/web-ui.ts CHANGED
@@ -1074,9 +1074,9 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1074
1074
  .composer-shell {
1075
1075
  background: #0a0a0a;
1076
1076
  border: 1px solid rgba(255,255,255,0.1);
1077
- border-radius: 9999px;
1077
+ border-radius: 24px;
1078
1078
  display: flex;
1079
- align-items: center;
1079
+ align-items: end;
1080
1080
  padding: 4px 6px 4px 18px;
1081
1081
  transition: border-color 0.15s;
1082
1082
  }
@@ -1093,7 +1093,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1093
1093
  padding: 10px 0 8px;
1094
1094
  font-size: 14px;
1095
1095
  line-height: 1.5;
1096
- margin-top: -2px;
1096
+ margin-top: -4px;
1097
1097
  }
1098
1098
  .composer-input::placeholder { color: #444; }
1099
1099
  .send-btn {
@@ -1111,6 +1111,11 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1111
1111
  transition: background 0.15s, opacity 0.15s;
1112
1112
  }
1113
1113
  .send-btn:hover { background: #fff; }
1114
+ .send-btn.stop-mode {
1115
+ background: #4a4a4a;
1116
+ color: #fff;
1117
+ }
1118
+ .send-btn.stop-mode:hover { background: #565656; }
1114
1119
  .send-btn:disabled { opacity: 0.2; cursor: default; }
1115
1120
  .send-btn:disabled:hover { background: #ededed; }
1116
1121
  .disclaimer {
@@ -1238,6 +1243,9 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1238
1243
  activeConversationId: null,
1239
1244
  activeMessages: [],
1240
1245
  isStreaming: false,
1246
+ activeStreamAbortController: null,
1247
+ activeStreamConversationId: null,
1248
+ activeStreamRunId: null,
1241
1249
  isMessagesPinnedToBottom: true,
1242
1250
  confirmDeleteId: null,
1243
1251
  approvalRequestsInFlight: {}
@@ -1264,6 +1272,10 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1264
1272
  sidebarToggle: $("sidebar-toggle"),
1265
1273
  sidebarBackdrop: $("sidebar-backdrop")
1266
1274
  };
1275
+ const sendIconMarkup =
1276
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1277
+ const stopIconMarkup =
1278
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="4" y="4" width="8" height="8" rx="2" fill="currentColor"/></svg>';
1267
1279
 
1268
1280
  const pushConversationUrl = (conversationId) => {
1269
1281
  const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
@@ -1950,6 +1962,24 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1950
1962
  }
1951
1963
  renderIfActiveConversation(false);
1952
1964
  }
1965
+ if (eventName === "run:cancelled") {
1966
+ assistantMessage._activeActivities = [];
1967
+ if (assistantMessage._currentTools.length > 0) {
1968
+ assistantMessage._sections.push({
1969
+ type: "tools",
1970
+ content: assistantMessage._currentTools,
1971
+ });
1972
+ assistantMessage._currentTools = [];
1973
+ }
1974
+ if (assistantMessage._currentText.length > 0) {
1975
+ assistantMessage._sections.push({
1976
+ type: "text",
1977
+ content: assistantMessage._currentText,
1978
+ });
1979
+ assistantMessage._currentText = "";
1980
+ }
1981
+ renderIfActiveConversation(false);
1982
+ }
1953
1983
  if (eventName === "run:error") {
1954
1984
  assistantMessage._activeActivities = [];
1955
1985
  const errMsg =
@@ -2023,7 +2053,15 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2023
2053
 
2024
2054
  const setStreaming = (value) => {
2025
2055
  state.isStreaming = value;
2026
- elements.send.disabled = value;
2056
+ const canStop = value && !!state.activeStreamRunId;
2057
+ elements.send.disabled = value ? !canStop : false;
2058
+ elements.send.innerHTML = value ? stopIconMarkup : sendIconMarkup;
2059
+ elements.send.classList.toggle("stop-mode", value);
2060
+ elements.send.setAttribute("aria-label", value ? "Stop response" : "Send message");
2061
+ elements.send.setAttribute(
2062
+ "title",
2063
+ value ? (canStop ? "Stop response" : "Starting response...") : "Send message",
2064
+ );
2027
2065
  };
2028
2066
 
2029
2067
  const pushToolActivity = (assistantMessage, line) => {
@@ -2204,6 +2242,36 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2204
2242
  el.style.overflowY = scrollHeight > 200 ? "auto" : "hidden";
2205
2243
  };
2206
2244
 
2245
+ const stopActiveRun = async () => {
2246
+ const stopRunId = state.activeStreamRunId;
2247
+ if (!stopRunId) return;
2248
+ const conversationId = state.activeStreamConversationId || state.activeConversationId;
2249
+ if (!conversationId) return;
2250
+ // Disable the stop button immediately so the user sees feedback.
2251
+ state.activeStreamRunId = null;
2252
+ setStreaming(state.isStreaming);
2253
+ // Signal the server to cancel the run. The server will emit
2254
+ // run:cancelled through the still-open SSE stream, which
2255
+ // sendMessage() processes naturally – the stream ends on its own
2256
+ // and cleanup happens in one finally block. No fetch abort needed.
2257
+ try {
2258
+ await api(
2259
+ "/api/conversations/" + encodeURIComponent(conversationId) + "/stop",
2260
+ {
2261
+ method: "POST",
2262
+ body: JSON.stringify({ runId: stopRunId }),
2263
+ },
2264
+ );
2265
+ } catch (e) {
2266
+ console.warn("Failed to stop conversation run:", e);
2267
+ // Fallback: abort the local fetch so the UI at least stops.
2268
+ const abortController = state.activeStreamAbortController;
2269
+ if (abortController && !abortController.signal.aborted) {
2270
+ abortController.abort();
2271
+ }
2272
+ }
2273
+ };
2274
+
2207
2275
  const sendMessage = async (text) => {
2208
2276
  const messageText = (text || "").trim();
2209
2277
  if (!messageText || state.isStreaming) {
@@ -2223,12 +2291,16 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2223
2291
  localMessages.push(assistantMessage);
2224
2292
  state.activeMessages = localMessages;
2225
2293
  renderMessages(localMessages, true, { forceScrollBottom: true });
2226
- setStreaming(true);
2227
2294
  let conversationId = state.activeConversationId;
2295
+ const streamAbortController = new AbortController();
2296
+ state.activeStreamAbortController = streamAbortController;
2297
+ state.activeStreamRunId = null;
2298
+ setStreaming(true);
2228
2299
  try {
2229
2300
  if (!conversationId) {
2230
2301
  conversationId = await createConversation(messageText, { loadConversation: false });
2231
2302
  }
2303
+ state.activeStreamConversationId = conversationId;
2232
2304
  const streamConversationId = conversationId;
2233
2305
  const renderIfActiveConversation = (streaming) => {
2234
2306
  if (state.activeConversationId !== streamConversationId) {
@@ -2237,11 +2309,23 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2237
2309
  state.activeMessages = localMessages;
2238
2310
  renderMessages(localMessages, streaming);
2239
2311
  };
2312
+ const finalizeAssistantMessage = () => {
2313
+ assistantMessage._activeActivities = [];
2314
+ if (assistantMessage._currentTools.length > 0) {
2315
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
2316
+ assistantMessage._currentTools = [];
2317
+ }
2318
+ if (assistantMessage._currentText.length > 0) {
2319
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
2320
+ assistantMessage._currentText = "";
2321
+ }
2322
+ };
2240
2323
  const response = await fetch("/api/conversations/" + encodeURIComponent(conversationId) + "/messages", {
2241
2324
  method: "POST",
2242
2325
  credentials: "include",
2243
2326
  headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
2244
- body: JSON.stringify({ message: messageText })
2327
+ body: JSON.stringify({ message: messageText }),
2328
+ signal: streamAbortController.signal,
2245
2329
  });
2246
2330
  if (!response.ok || !response.body) {
2247
2331
  throw new Error("Failed to stream response");
@@ -2268,6 +2352,10 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2268
2352
  assistantMessage._currentText += chunk;
2269
2353
  renderIfActiveConversation(true);
2270
2354
  }
2355
+ if (eventName === "run:started") {
2356
+ state.activeStreamRunId = typeof payload.runId === "string" ? payload.runId : null;
2357
+ setStreaming(state.isStreaming);
2358
+ }
2271
2359
  if (eventName === "tool:started") {
2272
2360
  const toolName = payload.tool || "tool";
2273
2361
  const startedActivity = addActiveActivityFromToolStart(
@@ -2413,19 +2501,14 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2413
2501
  renderIfActiveConversation(true);
2414
2502
  }
2415
2503
  if (eventName === "run:completed") {
2416
- assistantMessage._activeActivities = [];
2504
+ finalizeAssistantMessage();
2417
2505
  if (!assistantMessage.content || assistantMessage.content.length === 0) {
2418
2506
  assistantMessage.content = String(payload.result?.response || "");
2419
2507
  }
2420
- // Finalize sections: push any remaining tools and text
2421
- if (assistantMessage._currentTools.length > 0) {
2422
- assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
2423
- assistantMessage._currentTools = [];
2424
- }
2425
- if (assistantMessage._currentText.length > 0) {
2426
- assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
2427
- assistantMessage._currentText = "";
2428
- }
2508
+ renderIfActiveConversation(false);
2509
+ }
2510
+ if (eventName === "run:cancelled") {
2511
+ finalizeAssistantMessage();
2429
2512
  renderIfActiveConversation(false);
2430
2513
  }
2431
2514
  if (eventName === "run:error") {
@@ -2446,7 +2529,31 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2446
2529
  }
2447
2530
  await loadConversations();
2448
2531
  // Don't reload the conversation - we already have the latest state with tool chips
2532
+ } catch (error) {
2533
+ if (streamAbortController.signal.aborted) {
2534
+ assistantMessage._activeActivities = [];
2535
+ if (assistantMessage._currentTools.length > 0) {
2536
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
2537
+ assistantMessage._currentTools = [];
2538
+ }
2539
+ if (assistantMessage._currentText.length > 0) {
2540
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
2541
+ assistantMessage._currentText = "";
2542
+ }
2543
+ renderMessages(localMessages, false);
2544
+ } else {
2545
+ assistantMessage._activeActivities = [];
2546
+ assistantMessage._error = error instanceof Error ? error.message : "Something went wrong";
2547
+ renderMessages(localMessages, false);
2548
+ }
2449
2549
  } finally {
2550
+ if (state.activeStreamAbortController === streamAbortController) {
2551
+ state.activeStreamAbortController = null;
2552
+ }
2553
+ if (state.activeStreamConversationId === conversationId) {
2554
+ state.activeStreamConversationId = null;
2555
+ }
2556
+ state.activeStreamRunId = null;
2450
2557
  setStreaming(false);
2451
2558
  elements.prompt.focus();
2452
2559
  }
@@ -2549,6 +2656,13 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2549
2656
 
2550
2657
  elements.composer.addEventListener("submit", async (event) => {
2551
2658
  event.preventDefault();
2659
+ if (state.isStreaming) {
2660
+ if (!state.activeStreamRunId) {
2661
+ return;
2662
+ }
2663
+ await stopActiveRun();
2664
+ return;
2665
+ }
2552
2666
  const value = elements.prompt.value;
2553
2667
  elements.prompt.value = "";
2554
2668
  autoResizePrompt();