@runtypelabs/persona 3.21.3 → 3.23.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 (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
package/src/ui.ts CHANGED
@@ -33,6 +33,11 @@ import { resolveTokenValue } from "./utils/tokens";
33
33
  import { renderLucideIcon } from "./utils/icons";
34
34
  import { createElement, createElementInDocument } from "./utils/dom";
35
35
  import { morphMessages } from "./utils/morph";
36
+ import {
37
+ navigateComposerHistory,
38
+ INITIAL_HISTORY_STATE,
39
+ type ComposerHistoryState
40
+ } from "./utils/composer-history";
36
41
  import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
37
42
  import {
38
43
  createFollowStateController,
@@ -84,6 +89,7 @@ import { createApprovalBubble } from "./components/approval-bubble";
84
89
  import { createSuggestions } from "./components/suggestions";
85
90
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
86
91
  import { EventStreamStore } from "./utils/event-stream-store";
92
+ import { ThroughputTracker } from "./utils/throughput-tracker";
87
93
  import { createEventStreamView } from "./components/event-stream-view";
88
94
  import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
89
95
  import {
@@ -664,6 +670,8 @@ export const createAgentExperience = (
664
670
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
665
671
  const eventStreamMaxEvents = config.features?.eventStream?.maxEvents ?? 2000;
666
672
  let eventStreamBuffer = showEventStreamToggle ? new EventStreamBuffer(eventStreamMaxEvents, eventStreamStore) : null;
673
+ // Passive output-throughput tracker, fed from the same SSE tap as the buffer.
674
+ let throughputTracker = showEventStreamToggle ? new ThroughputTracker() : null;
667
675
  let eventStreamView: ReturnType<typeof createEventStreamView> | null = null;
668
676
  let eventStreamVisible = false;
669
677
  let eventStreamRAF: number | null = null;
@@ -853,6 +861,8 @@ export const createAgentExperience = (
853
861
  onClose: () => toggleEventStreamOff(),
854
862
  config,
855
863
  plugins,
864
+ getThroughput: () =>
865
+ throughputTracker?.getMetric() ?? { status: "idle" },
856
866
  });
857
867
  }
858
868
  if (eventStreamView) {
@@ -1398,8 +1408,14 @@ export const createAgentExperience = (
1398
1408
  });
1399
1409
  }
1400
1410
 
1401
- // Resolve the approval
1402
- session.resolveApproval(approvalMessage.approval, decision);
1411
+ // WebMCP gate approvals resolve a local Promise the bridge is parked on
1412
+ // (no server round-trip); server-driven approvals call the API. The
1413
+ // `toolType` marker set in `requestWebMcpApproval` discriminates the two.
1414
+ if (approvalMessage.approval.toolType === "webmcp") {
1415
+ session.resolveWebMcpApproval(messageId, decision);
1416
+ } else {
1417
+ session.resolveApproval(approvalMessage.approval, decision);
1418
+ }
1403
1419
  });
1404
1420
 
1405
1421
  let artifactPaneApi: ArtifactPaneApi | null = null;
@@ -4497,6 +4513,7 @@ export const createAgentExperience = (
4497
4513
  if (eventStreamBuffer || config.onSSEEvent) {
4498
4514
  session.setSSEEventCallback((type: string, payload: unknown) => {
4499
4515
  config.onSSEEvent?.(type, payload);
4516
+ throughputTracker?.processEvent(type, payload);
4500
4517
  eventStreamBuffer?.push({
4501
4518
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4502
4519
  type,
@@ -4552,6 +4569,10 @@ export const createAgentExperience = (
4552
4569
  // intact so the user can edit and resend without retyping.
4553
4570
  if (session.isStreaming()) {
4554
4571
  session.cancel();
4572
+ // Cancelling emits no terminal/error SSE frame, so reset the throughput
4573
+ // tracker (as clear-chat does) to avoid a stale `running` row lingering.
4574
+ throughputTracker?.reset();
4575
+ eventStreamView?.update();
4555
4576
  return;
4556
4577
  }
4557
4578
 
@@ -4577,6 +4598,7 @@ export const createAgentExperience = (
4577
4598
 
4578
4599
  textarea.value = "";
4579
4600
  textarea.style.height = "auto"; // Reset height after clearing
4601
+ resetHistoryNavigation();
4580
4602
 
4581
4603
  // Send message with optional content parts
4582
4604
  session.sendMessage(value, { contentParts });
@@ -4587,13 +4609,113 @@ export const createAgentExperience = (
4587
4609
  }
4588
4610
  };
4589
4611
 
4590
- const handleInputEnter = (event: KeyboardEvent) => {
4612
+ // --- Composer message-history navigation (Up/Down arrows) ---
4613
+ // Lets users recall and edit previously sent messages, shell/Slack style.
4614
+ // The pure state machine lives in utils/composer-history.ts; here we feed it
4615
+ // caret info and apply the value it returns. Text-only recall — attachments
4616
+ // on past messages are not restored.
4617
+ const historyNavigationEnabled = () =>
4618
+ config.features?.composerHistory !== false;
4619
+
4620
+ let composerHistoryState: ComposerHistoryState = { ...INITIAL_HISTORY_STATE };
4621
+ // Guards the reset-on-edit listener so our own programmatic value sets (which
4622
+ // dispatch an `input` event for auto-resize) don't exit navigation mode.
4623
+ let suppressHistoryReset = false;
4624
+
4625
+ const resetHistoryNavigation = () => {
4626
+ composerHistoryState = { ...INITIAL_HISTORY_STATE };
4627
+ };
4628
+
4629
+ const getUserMessageHistory = (): string[] =>
4630
+ session
4631
+ .getMessages()
4632
+ .filter((message) => message.role === "user")
4633
+ .map((message) => message.content ?? "")
4634
+ .filter((text) => text.length > 0);
4635
+
4636
+ const applyHistoryValue = (value: string) => {
4637
+ if (!textarea) return;
4638
+ suppressHistoryReset = true;
4639
+ textarea.value = value;
4640
+ // Trigger the auto-resize handler (it listens on `input`).
4641
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
4642
+ suppressHistoryReset = false;
4643
+ // Caret to end for natural editing / appending.
4644
+ const end = textarea.value.length;
4645
+ textarea.setSelectionRange(end, end);
4646
+ };
4647
+
4648
+ const handleComposerInput = () => {
4649
+ // A real edit leaves history-navigation mode.
4650
+ if (suppressHistoryReset) return;
4651
+ resetHistoryNavigation();
4652
+ };
4653
+
4654
+ const handleComposerKeydown = (event: KeyboardEvent) => {
4655
+ if (!textarea) return;
4656
+
4657
+ // Up/Down: walk through previously sent user messages.
4658
+ if (
4659
+ historyNavigationEnabled() &&
4660
+ (event.key === "ArrowUp" || event.key === "ArrowDown") &&
4661
+ !event.shiftKey &&
4662
+ !event.metaKey &&
4663
+ !event.ctrlKey &&
4664
+ !event.altKey &&
4665
+ !event.isComposing
4666
+ ) {
4667
+ const atStart =
4668
+ textarea.selectionStart === 0 && textarea.selectionEnd === 0;
4669
+ const result = navigateComposerHistory({
4670
+ direction: event.key === "ArrowUp" ? "up" : "down",
4671
+ history: getUserMessageHistory(),
4672
+ currentValue: textarea.value,
4673
+ atStart,
4674
+ state: composerHistoryState
4675
+ });
4676
+ composerHistoryState = result.state;
4677
+ if (result.handled) {
4678
+ event.preventDefault();
4679
+ if (result.value !== undefined) {
4680
+ applyHistoryValue(result.value);
4681
+ }
4682
+ return;
4683
+ }
4684
+ // Not handled — fall through to default cursor movement.
4685
+ }
4686
+
4687
+ // Enter: send, unless a response is streaming. While streaming, Enter is
4688
+ // inert (never a stop trigger) — the visible Stop button / Esc stop it.
4591
4689
  if (event.key === "Enter" && !event.shiftKey) {
4690
+ if (session.isStreaming()) {
4691
+ event.preventDefault();
4692
+ return;
4693
+ }
4694
+ resetHistoryNavigation();
4592
4695
  event.preventDefault();
4593
4696
  sendButton.click();
4594
4697
  }
4595
4698
  };
4596
4699
 
4700
+ // Esc-to-stop: while a response streams, Escape within this widget aborts it.
4701
+ // Capture phase + registered at init so it runs before the composer-bar Esc
4702
+ // collapse listener (attached later on open); stopImmediatePropagation keeps
4703
+ // a stream-stop from also collapsing the panel. Scoped via composedPath so a
4704
+ // page-wide Escape elsewhere doesn't hijack.
4705
+ const handleEscStop = (event: KeyboardEvent) => {
4706
+ if (event.key !== "Escape" || event.isComposing) return;
4707
+ if (!session.isStreaming()) return;
4708
+ if (!event.composedPath().includes(container)) return;
4709
+ session.cancel();
4710
+ // Cancelling emits no terminal/error SSE frame — reset throughput so the
4711
+ // Events row doesn't keep showing a live rate from the stopped stream.
4712
+ throughputTracker?.reset();
4713
+ eventStreamView?.update();
4714
+ resetHistoryNavigation();
4715
+ event.preventDefault();
4716
+ event.stopImmediatePropagation();
4717
+ };
4718
+
4597
4719
  const handleInputPaste = async (event: ClipboardEvent) => {
4598
4720
  if (config.attachments?.enabled !== true || !attachmentManager) return;
4599
4721
 
@@ -5454,8 +5576,9 @@ export const createAgentExperience = (
5454
5576
  persistentMetadata = {};
5455
5577
  actionManager.syncFromMetadata();
5456
5578
 
5457
- // Clear event stream buffer and store
5579
+ // Clear event stream buffer and store, and reset throughput tracking
5458
5580
  eventStreamBuffer?.clear();
5581
+ throughputTracker?.reset();
5459
5582
  eventStreamView?.update();
5460
5583
  });
5461
5584
  };
@@ -5465,9 +5588,13 @@ export const createAgentExperience = (
5465
5588
  if (composerForm) {
5466
5589
  composerForm.addEventListener("submit", handleSubmit);
5467
5590
  }
5468
- textarea?.addEventListener("keydown", handleInputEnter);
5591
+ textarea?.addEventListener("keydown", handleComposerKeydown);
5592
+ textarea?.addEventListener("input", handleComposerInput);
5469
5593
  textarea?.addEventListener("paste", handleInputPaste);
5470
5594
 
5595
+ const escStopDoc = mount.ownerDocument ?? document;
5596
+ escStopDoc.addEventListener("keydown", handleEscStop, true);
5597
+
5471
5598
  const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
5472
5599
  let attachmentFileDragDepth = 0;
5473
5600
 
@@ -5544,8 +5671,10 @@ export const createAgentExperience = (
5544
5671
  if (composerForm) {
5545
5672
  composerForm.removeEventListener("submit", handleSubmit);
5546
5673
  }
5547
- textarea?.removeEventListener("keydown", handleInputEnter);
5674
+ textarea?.removeEventListener("keydown", handleComposerKeydown);
5675
+ textarea?.removeEventListener("input", handleComposerInput);
5548
5676
  textarea?.removeEventListener("paste", handleInputPaste);
5677
+ escStopDoc.removeEventListener("keydown", handleEscStop, true);
5549
5678
  });
5550
5679
 
5551
5680
  destroyCallbacks.push(() => {
@@ -5618,10 +5747,12 @@ export const createAgentExperience = (
5618
5747
  if (!eventStreamBuffer) {
5619
5748
  eventStreamStore = new EventStreamStore(eventStreamDbName);
5620
5749
  eventStreamBuffer = new EventStreamBuffer(eventStreamMaxEvents, eventStreamStore);
5750
+ throughputTracker = throughputTracker ?? new ThroughputTracker();
5621
5751
  eventStreamStore.open().then(() => eventStreamBuffer?.restore()).catch(() => {});
5622
- // Register the SSE event callback (host tap + buffer)
5752
+ // Register the SSE event callback (host tap + buffer + throughput)
5623
5753
  session.setSSEEventCallback((type: string, payload: unknown) => {
5624
5754
  config.onSSEEvent?.(type, payload);
5755
+ throughputTracker?.processEvent(type, payload);
5625
5756
  eventStreamBuffer!.push({
5626
5757
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
5627
5758
  type,
@@ -5670,6 +5801,8 @@ export const createAgentExperience = (
5670
5801
  eventStreamStore?.destroy();
5671
5802
  eventStreamBuffer = null;
5672
5803
  eventStreamStore = null;
5804
+ throughputTracker?.reset();
5805
+ throughputTracker = null;
5673
5806
  }
5674
5807
 
5675
5808
  if (config.launcher?.enabled === false && launcherButtonInstance) {
@@ -6910,8 +7043,9 @@ export const createAgentExperience = (
6910
7043
  persistentMetadata = {};
6911
7044
  actionManager.syncFromMetadata();
6912
7045
 
6913
- // Clear event stream buffer and store
7046
+ // Clear event stream buffer and store, and reset throughput tracking
6914
7047
  eventStreamBuffer?.clear();
7048
+ throughputTracker?.reset();
6915
7049
  eventStreamView?.update();
6916
7050
  },
6917
7051
  setMessage(message: string): boolean {
@@ -7065,6 +7199,7 @@ export const createAgentExperience = (
7065
7199
  /** Push a raw event into the event stream buffer (for testing/debugging) */
7066
7200
  __pushEventStreamEvent(event: { type: string; payload: unknown }): void {
7067
7201
  if (eventStreamBuffer) {
7202
+ throughputTracker?.processEvent(event.type, event.payload);
7068
7203
  eventStreamBuffer.push({
7069
7204
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
7070
7205
  type: event.type,
@@ -7131,6 +7266,14 @@ export const createAgentExperience = (
7131
7266
  if (!approvalMessage?.approval) {
7132
7267
  throw new Error(`Approval not found: ${approvalId}`);
7133
7268
  }
7269
+ // Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
7270
+ // Promise the bridge is parked on (no server round-trip and they carry an
7271
+ // empty executionId/agentId), so they must NOT hit the server approval
7272
+ // API. Route by the `toolType` marker set in `requestWebMcpApproval`.
7273
+ if (approvalMessage.approval.toolType === "webmcp") {
7274
+ session.resolveWebMcpApproval(approvalMessage.id, decision);
7275
+ return;
7276
+ }
7134
7277
  return session.resolveApproval(approvalMessage.approval, decision);
7135
7278
  },
7136
7279
  getMessages() {
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ navigateComposerHistory,
4
+ INITIAL_HISTORY_STATE,
5
+ type ComposerHistoryState
6
+ } from "./composer-history";
7
+
8
+ const history = ["first", "second", "third"]; // oldest -> newest
9
+
10
+ const up = (
11
+ state: ComposerHistoryState,
12
+ overrides: Partial<Parameters<typeof navigateComposerHistory>[0]> = {}
13
+ ) =>
14
+ navigateComposerHistory({
15
+ direction: "up",
16
+ history,
17
+ currentValue: "",
18
+ atStart: true,
19
+ state,
20
+ ...overrides
21
+ });
22
+
23
+ const down = (
24
+ state: ComposerHistoryState,
25
+ overrides: Partial<Parameters<typeof navigateComposerHistory>[0]> = {}
26
+ ) =>
27
+ navigateComposerHistory({
28
+ direction: "down",
29
+ history,
30
+ currentValue: "",
31
+ atStart: true,
32
+ state,
33
+ ...overrides
34
+ });
35
+
36
+ describe("navigateComposerHistory", () => {
37
+ it("Up from a fresh composer recalls the newest message and saves the draft", () => {
38
+ const result = up(
39
+ { ...INITIAL_HISTORY_STATE },
40
+ { currentValue: "my draft" }
41
+ );
42
+ expect(result.handled).toBe(true);
43
+ expect(result.value).toBe("third");
44
+ expect(result.state).toEqual({ index: 2, draft: "my draft" });
45
+ });
46
+
47
+ it("repeated Up steps toward older messages", () => {
48
+ let state = up({ ...INITIAL_HISTORY_STATE }).state;
49
+ expect(state.index).toBe(2);
50
+
51
+ const second = up(state);
52
+ expect(second.value).toBe("second");
53
+ state = second.state;
54
+
55
+ const third = up(state);
56
+ expect(third.value).toBe("first");
57
+ state = third.state;
58
+ expect(state.index).toBe(0);
59
+ });
60
+
61
+ it("Up at the oldest entry is consumed but does not change the value", () => {
62
+ const state: ComposerHistoryState = { index: 0, draft: "" };
63
+ const result = up(state);
64
+ expect(result.handled).toBe(true);
65
+ expect(result.value).toBeUndefined();
66
+ expect(result.state.index).toBe(0);
67
+ });
68
+
69
+ it("does not hijack Up when not navigating and the caret is not at the start", () => {
70
+ const result = up({ ...INITIAL_HISTORY_STATE }, { atStart: false });
71
+ expect(result.handled).toBe(false);
72
+ expect(result.value).toBeUndefined();
73
+ });
74
+
75
+ it("continues navigating Up even when the caret is not at the start", () => {
76
+ // Already in history mode (caret sits at end after a recall).
77
+ const state: ComposerHistoryState = { index: 2, draft: "draft" };
78
+ const result = up(state, { atStart: false });
79
+ expect(result.handled).toBe(true);
80
+ expect(result.value).toBe("second");
81
+ });
82
+
83
+ it("Down steps toward newer messages while navigating", () => {
84
+ const state: ComposerHistoryState = { index: 0, draft: "draft" };
85
+ const result = down(state);
86
+ expect(result.handled).toBe(true);
87
+ expect(result.value).toBe("second");
88
+ expect(result.state.index).toBe(1);
89
+ });
90
+
91
+ it("Down past the newest entry restores the saved draft and exits", () => {
92
+ const state: ComposerHistoryState = { index: 2, draft: "saved draft" };
93
+ const result = down(state);
94
+ expect(result.handled).toBe(true);
95
+ expect(result.value).toBe("saved draft");
96
+ expect(result.state).toEqual(INITIAL_HISTORY_STATE);
97
+ });
98
+
99
+ it("Down does nothing when not navigating history", () => {
100
+ const result = down({ ...INITIAL_HISTORY_STATE });
101
+ expect(result.handled).toBe(false);
102
+ expect(result.value).toBeUndefined();
103
+ });
104
+
105
+ it("does nothing when there is no history", () => {
106
+ const result = navigateComposerHistory({
107
+ direction: "up",
108
+ history: [],
109
+ currentValue: "",
110
+ atStart: true,
111
+ state: { ...INITIAL_HISTORY_STATE }
112
+ });
113
+ expect(result.handled).toBe(false);
114
+ });
115
+
116
+ it("round-trips: Up then Down returns to the original draft", () => {
117
+ const draft = "in progress";
118
+ let state = { ...INITIAL_HISTORY_STATE };
119
+
120
+ const u1 = up(state, { currentValue: draft });
121
+ state = u1.state;
122
+ expect(u1.value).toBe("third");
123
+
124
+ const d1 = down(state); // back past newest -> restore draft
125
+ expect(d1.value).toBe(draft);
126
+ expect(d1.state).toEqual(INITIAL_HISTORY_STATE);
127
+ });
128
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure state machine for composer message-history navigation (Up/Down arrows).
3
+ *
4
+ * Mirrors the shell / Slack convention: pressing Up recalls previously sent
5
+ * user messages for re-entry or editing; Down walks back toward the present
6
+ * and restores the in-progress draft once you page past the newest entry.
7
+ *
8
+ * Kept free of DOM access so it can be unit-tested in the Node test env. The
9
+ * UI layer (`ui.ts`) supplies caret information and applies the returned value
10
+ * to the textarea.
11
+ */
12
+
13
+ export interface ComposerHistoryState {
14
+ /** Index into the history list, or -1 when not navigating. */
15
+ index: number;
16
+ /** The user's in-progress text, saved when navigation begins. */
17
+ draft: string;
18
+ }
19
+
20
+ export const INITIAL_HISTORY_STATE: ComposerHistoryState = {
21
+ index: -1,
22
+ draft: ""
23
+ };
24
+
25
+ export interface ComposerHistoryInput {
26
+ direction: "up" | "down";
27
+ /** Previously sent user-message texts, oldest first. */
28
+ history: string[];
29
+ /** Current textarea value (saved as the draft when navigation begins). */
30
+ currentValue: string;
31
+ /** True when the caret sits at the very start of the textarea. */
32
+ atStart: boolean;
33
+ state: ComposerHistoryState;
34
+ }
35
+
36
+ export interface ComposerHistoryResult {
37
+ /** Whether the key was consumed (caller should preventDefault). */
38
+ handled: boolean;
39
+ /** New textarea value to apply — only present when it should change. */
40
+ value?: string;
41
+ /** Next navigation state. */
42
+ state: ComposerHistoryState;
43
+ }
44
+
45
+ /**
46
+ * Compute the next navigation state for an Up/Down key press.
47
+ *
48
+ * - **Up** enters history only from the top boundary (`atStart`), then keeps
49
+ * stepping toward older messages on each subsequent press while navigating.
50
+ * - **Down** only acts while already navigating, stepping toward newer messages
51
+ * and finally restoring the saved draft once it walks past the newest entry.
52
+ */
53
+ export function navigateComposerHistory(
54
+ input: ComposerHistoryInput
55
+ ): ComposerHistoryResult {
56
+ const { direction, history, currentValue, atStart, state } = input;
57
+ const inHistory = state.index !== -1;
58
+
59
+ if (history.length === 0) {
60
+ return { handled: false, state };
61
+ }
62
+
63
+ if (direction === "up") {
64
+ // Only hijack Up from the top boundary so normal multi-line cursor
65
+ // movement keeps working until the user is actually cycling history.
66
+ if (!inHistory && !atStart) {
67
+ return { handled: false, state };
68
+ }
69
+
70
+ if (!inHistory) {
71
+ // First step: stash the draft and jump to the newest entry.
72
+ const index = history.length - 1;
73
+ return {
74
+ handled: true,
75
+ value: history[index],
76
+ state: { index, draft: currentValue }
77
+ };
78
+ }
79
+
80
+ if (state.index > 0) {
81
+ const index = state.index - 1;
82
+ return {
83
+ handled: true,
84
+ value: history[index],
85
+ state: { index, draft: state.draft }
86
+ };
87
+ }
88
+
89
+ // Already at the oldest entry — consume the key but don't change.
90
+ return { handled: true, state };
91
+ }
92
+
93
+ // direction === "down": only meaningful while navigating history.
94
+ if (!inHistory) {
95
+ return { handled: false, state };
96
+ }
97
+
98
+ if (state.index < history.length - 1) {
99
+ const index = state.index + 1;
100
+ return {
101
+ handled: true,
102
+ value: history[index],
103
+ state: { index, draft: state.draft }
104
+ };
105
+ }
106
+
107
+ // Stepped past the newest entry — restore the saved draft and exit.
108
+ return {
109
+ handled: true,
110
+ value: state.draft,
111
+ state: { ...INITIAL_HISTORY_STATE }
112
+ };
113
+ }
@@ -125,6 +125,26 @@ describe("computeMessageFingerprint", () => {
125
125
  expect(fp1).not.toBe(fp2);
126
126
  });
127
127
 
128
+ it("changes when reasoning content grows while chunk count stays at 1 (ordered streaming path)", () => {
129
+ // The sequenced/production path collapses reasoning to a single accumulated
130
+ // chunk (client.ts: `reasoning.chunks = [ordered]`), so chunks.length is
131
+ // permanently 1 while the text grows. A length-only fingerprint would freeze
132
+ // the reasoning bubble mid-stream; hashing the last chunk's length + tail
133
+ // keeps the cache invalidating on every delta.
134
+ const fp1 = computeMessageFingerprint(
135
+ makeMessage({ variant: "reasoning", reasoning: { chunks: ["I am thinking about"], status: "streaming" } }),
136
+ 0
137
+ );
138
+ const fp2 = computeMessageFingerprint(
139
+ makeMessage({
140
+ variant: "reasoning",
141
+ reasoning: { chunks: ["I am thinking about the user's question"], status: "streaming" },
142
+ }),
143
+ 0
144
+ );
145
+ expect(fp1).not.toBe(fp2);
146
+ });
147
+
128
148
  it("changes when contentParts length changes", () => {
129
149
  const fp1 = computeMessageFingerprint(makeMessage({ contentParts: [] }), 0);
130
150
  const fp2 = computeMessageFingerprint(makeMessage({ contentParts: [{ type: "text", text: "hi" }] }), 0);
@@ -63,6 +63,8 @@ export function computeMessageFingerprint(
63
63
  ? JSON.stringify(message.toolCall.args).length
64
64
  : 0,
65
65
  message.reasoning?.chunks?.length ?? 0,
66
+ message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.length ?? 0,
67
+ message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.slice(-32) ?? "",
66
68
  message.contentParts?.length ?? 0,
67
69
  message.stopReason ?? "",
68
70
  configVersion,