@runtypelabs/persona 3.21.2 → 3.22.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.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- 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.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +50 -43
- 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 +98 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -41
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +1875 -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 +1848 -0
- package/dist/theme-editor.cjs +2282 -90
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +2267 -90
- package/package.json +9 -2
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/composer-parts.test.ts +34 -0
- package/src/components/composer-parts.ts +9 -6
- 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 +127 -5
- 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/{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,
|
|
@@ -1398,8 +1403,14 @@ export const createAgentExperience = (
|
|
|
1398
1403
|
});
|
|
1399
1404
|
}
|
|
1400
1405
|
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1406
|
+
// WebMCP gate approvals resolve a local Promise the bridge is parked on
|
|
1407
|
+
// (no server round-trip); server-driven approvals call the API. The
|
|
1408
|
+
// `toolType` marker set in `requestWebMcpApproval` discriminates the two.
|
|
1409
|
+
if (approvalMessage.approval.toolType === "webmcp") {
|
|
1410
|
+
session.resolveWebMcpApproval(messageId, decision);
|
|
1411
|
+
} else {
|
|
1412
|
+
session.resolveApproval(approvalMessage.approval, decision);
|
|
1413
|
+
}
|
|
1403
1414
|
});
|
|
1404
1415
|
|
|
1405
1416
|
let artifactPaneApi: ArtifactPaneApi | null = null;
|
|
@@ -4577,6 +4588,7 @@ export const createAgentExperience = (
|
|
|
4577
4588
|
|
|
4578
4589
|
textarea.value = "";
|
|
4579
4590
|
textarea.style.height = "auto"; // Reset height after clearing
|
|
4591
|
+
resetHistoryNavigation();
|
|
4580
4592
|
|
|
4581
4593
|
// Send message with optional content parts
|
|
4582
4594
|
session.sendMessage(value, { contentParts });
|
|
@@ -4587,13 +4599,109 @@ export const createAgentExperience = (
|
|
|
4587
4599
|
}
|
|
4588
4600
|
};
|
|
4589
4601
|
|
|
4590
|
-
|
|
4602
|
+
// --- Composer message-history navigation (Up/Down arrows) ---
|
|
4603
|
+
// Lets users recall and edit previously sent messages, shell/Slack style.
|
|
4604
|
+
// The pure state machine lives in utils/composer-history.ts; here we feed it
|
|
4605
|
+
// caret info and apply the value it returns. Text-only recall — attachments
|
|
4606
|
+
// on past messages are not restored.
|
|
4607
|
+
const historyNavigationEnabled = () =>
|
|
4608
|
+
config.features?.composerHistory !== false;
|
|
4609
|
+
|
|
4610
|
+
let composerHistoryState: ComposerHistoryState = { ...INITIAL_HISTORY_STATE };
|
|
4611
|
+
// Guards the reset-on-edit listener so our own programmatic value sets (which
|
|
4612
|
+
// dispatch an `input` event for auto-resize) don't exit navigation mode.
|
|
4613
|
+
let suppressHistoryReset = false;
|
|
4614
|
+
|
|
4615
|
+
const resetHistoryNavigation = () => {
|
|
4616
|
+
composerHistoryState = { ...INITIAL_HISTORY_STATE };
|
|
4617
|
+
};
|
|
4618
|
+
|
|
4619
|
+
const getUserMessageHistory = (): string[] =>
|
|
4620
|
+
session
|
|
4621
|
+
.getMessages()
|
|
4622
|
+
.filter((message) => message.role === "user")
|
|
4623
|
+
.map((message) => message.content ?? "")
|
|
4624
|
+
.filter((text) => text.length > 0);
|
|
4625
|
+
|
|
4626
|
+
const applyHistoryValue = (value: string) => {
|
|
4627
|
+
if (!textarea) return;
|
|
4628
|
+
suppressHistoryReset = true;
|
|
4629
|
+
textarea.value = value;
|
|
4630
|
+
// Trigger the auto-resize handler (it listens on `input`).
|
|
4631
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
4632
|
+
suppressHistoryReset = false;
|
|
4633
|
+
// Caret to end for natural editing / appending.
|
|
4634
|
+
const end = textarea.value.length;
|
|
4635
|
+
textarea.setSelectionRange(end, end);
|
|
4636
|
+
};
|
|
4637
|
+
|
|
4638
|
+
const handleComposerInput = () => {
|
|
4639
|
+
// A real edit leaves history-navigation mode.
|
|
4640
|
+
if (suppressHistoryReset) return;
|
|
4641
|
+
resetHistoryNavigation();
|
|
4642
|
+
};
|
|
4643
|
+
|
|
4644
|
+
const handleComposerKeydown = (event: KeyboardEvent) => {
|
|
4645
|
+
if (!textarea) return;
|
|
4646
|
+
|
|
4647
|
+
// Up/Down: walk through previously sent user messages.
|
|
4648
|
+
if (
|
|
4649
|
+
historyNavigationEnabled() &&
|
|
4650
|
+
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
|
4651
|
+
!event.shiftKey &&
|
|
4652
|
+
!event.metaKey &&
|
|
4653
|
+
!event.ctrlKey &&
|
|
4654
|
+
!event.altKey &&
|
|
4655
|
+
!event.isComposing
|
|
4656
|
+
) {
|
|
4657
|
+
const atStart =
|
|
4658
|
+
textarea.selectionStart === 0 && textarea.selectionEnd === 0;
|
|
4659
|
+
const result = navigateComposerHistory({
|
|
4660
|
+
direction: event.key === "ArrowUp" ? "up" : "down",
|
|
4661
|
+
history: getUserMessageHistory(),
|
|
4662
|
+
currentValue: textarea.value,
|
|
4663
|
+
atStart,
|
|
4664
|
+
state: composerHistoryState
|
|
4665
|
+
});
|
|
4666
|
+
composerHistoryState = result.state;
|
|
4667
|
+
if (result.handled) {
|
|
4668
|
+
event.preventDefault();
|
|
4669
|
+
if (result.value !== undefined) {
|
|
4670
|
+
applyHistoryValue(result.value);
|
|
4671
|
+
}
|
|
4672
|
+
return;
|
|
4673
|
+
}
|
|
4674
|
+
// Not handled — fall through to default cursor movement.
|
|
4675
|
+
}
|
|
4676
|
+
|
|
4677
|
+
// Enter: send, unless a response is streaming. While streaming, Enter is
|
|
4678
|
+
// inert (never a stop trigger) — the visible Stop button / Esc stop it.
|
|
4591
4679
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
4680
|
+
if (session.isStreaming()) {
|
|
4681
|
+
event.preventDefault();
|
|
4682
|
+
return;
|
|
4683
|
+
}
|
|
4684
|
+
resetHistoryNavigation();
|
|
4592
4685
|
event.preventDefault();
|
|
4593
4686
|
sendButton.click();
|
|
4594
4687
|
}
|
|
4595
4688
|
};
|
|
4596
4689
|
|
|
4690
|
+
// Esc-to-stop: while a response streams, Escape within this widget aborts it.
|
|
4691
|
+
// Capture phase + registered at init so it runs before the composer-bar Esc
|
|
4692
|
+
// collapse listener (attached later on open); stopImmediatePropagation keeps
|
|
4693
|
+
// a stream-stop from also collapsing the panel. Scoped via composedPath so a
|
|
4694
|
+
// page-wide Escape elsewhere doesn't hijack.
|
|
4695
|
+
const handleEscStop = (event: KeyboardEvent) => {
|
|
4696
|
+
if (event.key !== "Escape" || event.isComposing) return;
|
|
4697
|
+
if (!session.isStreaming()) return;
|
|
4698
|
+
if (!event.composedPath().includes(container)) return;
|
|
4699
|
+
session.cancel();
|
|
4700
|
+
resetHistoryNavigation();
|
|
4701
|
+
event.preventDefault();
|
|
4702
|
+
event.stopImmediatePropagation();
|
|
4703
|
+
};
|
|
4704
|
+
|
|
4597
4705
|
const handleInputPaste = async (event: ClipboardEvent) => {
|
|
4598
4706
|
if (config.attachments?.enabled !== true || !attachmentManager) return;
|
|
4599
4707
|
|
|
@@ -5465,9 +5573,13 @@ export const createAgentExperience = (
|
|
|
5465
5573
|
if (composerForm) {
|
|
5466
5574
|
composerForm.addEventListener("submit", handleSubmit);
|
|
5467
5575
|
}
|
|
5468
|
-
textarea?.addEventListener("keydown",
|
|
5576
|
+
textarea?.addEventListener("keydown", handleComposerKeydown);
|
|
5577
|
+
textarea?.addEventListener("input", handleComposerInput);
|
|
5469
5578
|
textarea?.addEventListener("paste", handleInputPaste);
|
|
5470
5579
|
|
|
5580
|
+
const escStopDoc = mount.ownerDocument ?? document;
|
|
5581
|
+
escStopDoc.addEventListener("keydown", handleEscStop, true);
|
|
5582
|
+
|
|
5471
5583
|
const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
|
|
5472
5584
|
let attachmentFileDragDepth = 0;
|
|
5473
5585
|
|
|
@@ -5544,8 +5656,10 @@ export const createAgentExperience = (
|
|
|
5544
5656
|
if (composerForm) {
|
|
5545
5657
|
composerForm.removeEventListener("submit", handleSubmit);
|
|
5546
5658
|
}
|
|
5547
|
-
textarea?.removeEventListener("keydown",
|
|
5659
|
+
textarea?.removeEventListener("keydown", handleComposerKeydown);
|
|
5660
|
+
textarea?.removeEventListener("input", handleComposerInput);
|
|
5548
5661
|
textarea?.removeEventListener("paste", handleInputPaste);
|
|
5662
|
+
escStopDoc.removeEventListener("keydown", handleEscStop, true);
|
|
5549
5663
|
});
|
|
5550
5664
|
|
|
5551
5665
|
destroyCallbacks.push(() => {
|
|
@@ -7131,6 +7245,14 @@ export const createAgentExperience = (
|
|
|
7131
7245
|
if (!approvalMessage?.approval) {
|
|
7132
7246
|
throw new Error(`Approval not found: ${approvalId}`);
|
|
7133
7247
|
}
|
|
7248
|
+
// Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
|
|
7249
|
+
// Promise the bridge is parked on (no server round-trip and they carry an
|
|
7250
|
+
// empty executionId/agentId), so they must NOT hit the server approval
|
|
7251
|
+
// API. Route by the `toolType` marker set in `requestWebMcpApproval`.
|
|
7252
|
+
if (approvalMessage.approval.toolType === "webmcp") {
|
|
7253
|
+
session.resolveWebMcpApproval(approvalMessage.id, decision);
|
|
7254
|
+
return;
|
|
7255
|
+
}
|
|
7134
7256
|
return session.resolveApproval(approvalMessage.approval, decision);
|
|
7135
7257
|
},
|
|
7136
7258
|
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,
|