@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.
- package/README.md +67 -0
- package/dist/animations/glyph-cycle.cjs +2 -262
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -235
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.cjs +2 -72
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations/wipe.js +2 -45
- package/dist/index.cjs +52 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +107 -97
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -45
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +23 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +23 -0
- package/dist/testing.cjs +3 -84
- package/dist/testing.js +3 -55
- package/dist/theme-editor.cjs +57 -22501
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +57 -22503
- package/package.json +16 -6
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/event-stream-view.ts +122 -1
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +151 -8
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/src/utils/throughput-tracker.test.ts +366 -0
- package/src/utils/throughput-tracker.ts +427 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- 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
|
-
//
|
|
1402
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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,
|