fix(ui): harden webchat input history behavior

Harden WebChat input history handling so draft, navigation, and render-state behavior stay consistent across the chat UI.

Validated locally on the rebased PR head 742a5f22f1:
- CI=true OPENCLAW_LOCAL_CHECK=0 pnpm check:changed
- CI=true OPENCLAW_LOCAL_CHECK=0 pnpm test:changed

Closes #38702.
This commit is contained in:
清秋
2026-04-27 19:08:55 +08:00
committed by GitHub
parent 1971db0dc5
commit 8200d878a3
11 changed files with 439 additions and 76 deletions

View File

@@ -14,6 +14,7 @@ vi.mock("./app-last-active-session.ts", () => ({
let handleSendChat: typeof import("./app-chat.ts").handleSendChat;
let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage;
let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory;
let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat;
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
@@ -22,6 +23,7 @@ async function loadChatHelpers(): Promise<void> {
({
handleSendChat,
steerQueuedChatMessage,
navigateChatInputHistory,
handleAbortChat,
refreshChatAvatar,
clearPendingQueueItemsForRun,
@@ -44,7 +46,13 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
chatMessages: [],
chatStream: null,
connected: true,
chatLoading: false,
chatMessage: "",
chatLocalInputHistoryBySession: {},
chatInputHistorySessionKey: null,
chatInputHistoryItems: null,
chatInputHistoryIndex: -1,
chatDraftBeforeHistory: null,
chatAttachments: [],
chatQueue: [],
chatRunId: null,
@@ -493,6 +501,8 @@ describe("handleSendChat", () => {
expect(host.chatStream).toBe("Working...");
expect(host.chatMessages).toEqual([]);
expect(host.chatMessage).toBe("");
expect(navigateChatInputHistory(host, "up")).toBe(true);
expect(host.chatMessage).toBe("/btw what changed?");
});
it("sends /btw without adopting a main chat run when idle", async () => {
@@ -519,6 +529,23 @@ describe("handleSendChat", () => {
expect(host.chatRunId).toBeNull();
expect(host.chatMessages).toEqual([]);
expect(host.chatMessage).toBe("");
expect(navigateChatInputHistory(host, "up")).toBe(true);
expect(host.chatMessage).toBe("/btw summarize this");
});
it("keeps queued normal messages recallable before transcript history catches up", async () => {
const host = makeHost({
chatMessage: "queued while busy",
chatRunId: "run-1",
});
await handleSendChat(host);
expect(host.chatQueue).toHaveLength(1);
expect(host.chatQueue[0]?.text).toBe("queued while busy");
expect(host.chatMessage).toBe("");
expect(navigateChatInputHistory(host, "up")).toBe(true);
expect(host.chatMessage).toBe("queued while busy");
});
it("restores the BTW draft when detached send fails", async () => {

View File

@@ -1,6 +1,16 @@
import { setLastActiveSessionKey } from "./app-last-active-session.ts";
import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
import { resetToolStream } from "./app-tool-stream.ts";
import {
handleChatDraftChange,
handleChatInputHistoryKey,
navigateChatInputHistory,
recordNonTranscriptInputHistory,
resetChatInputHistoryNavigation,
type ChatInputHistoryKeyInput,
type ChatInputHistoryKeyResult,
type ChatInputHistoryState,
} from "./chat/input-history.ts";
import type { ChatSideResult } from "./chat/side-result.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts";
@@ -25,18 +35,15 @@ import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
export type ChatHost = {
export type ChatHost = ChatInputHistoryState & {
client: GatewayBrowserClient | null;
chatMessages: unknown[];
chatStream: string | null;
connected: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
chatRunId: string | null;
chatSending: boolean;
lastError?: string | null;
sessionKey: string;
basePath: string;
settings?: { token?: string | null };
password?: string | null;
@@ -59,6 +66,13 @@ export type ChatHost = {
};
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
export {
handleChatDraftChange,
handleChatInputHistoryKey,
navigateChatInputHistory,
resetChatInputHistoryNavigation,
};
export type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult };
export function isChatBusy(host: ChatHost) {
return host.chatSending || Boolean(host.chatRunId);
@@ -102,6 +116,7 @@ export async function handleAbortChat(host: ChatHost) {
// If disconnected but we have an active runId, queue the abort for when we reconnect
if (!host.connected && host.chatRunId) {
host.chatMessage = "";
resetChatInputHistoryNavigation(host);
host.pendingAbort = { runId: host.chatRunId, sessionKey: host.sessionKey };
return;
}
@@ -109,6 +124,7 @@ export async function handleAbortChat(host: ChatHost) {
return;
}
host.chatMessage = "";
resetChatInputHistoryNavigation(host);
await abortChatRun(host as unknown as ChatState);
}
@@ -190,6 +206,7 @@ async function sendChatMessageNow(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
resetChatInputHistoryNavigation(host);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
@@ -337,14 +354,19 @@ export async function handleSendChat(
}
if (isChatStopCommand(message)) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
}
await handleAbortChat(host);
return;
}
if (isBtwCommand(message)) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
host.chatMessage = "";
host.chatAttachments = [];
resetChatInputHistoryNavigation(host);
}
await sendDetachedBtwMessage(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined,
@@ -359,8 +381,10 @@ export async function handleSendChat(
if (parsed?.command.executeLocal) {
if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.key)) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
host.chatMessage = "";
host.chatAttachments = [];
resetChatInputHistoryNavigation(host);
}
enqueueChatMessage(host, message, undefined, isChatResetCommand(message), {
args: parsed.args,
@@ -370,8 +394,10 @@ export async function handleSendChat(
}
const prevDraft = messageOverride == null ? previousDraft : undefined;
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
host.chatMessage = "";
host.chatAttachments = [];
resetChatInputHistoryNavigation(host);
}
await dispatchSlashCommand(host, parsed.command.key, parsed.args, {
previousDraft: prevDraft,
@@ -384,9 +410,13 @@ export async function handleSendChat(
if (messageOverride == null) {
host.chatMessage = "";
host.chatAttachments = [];
resetChatInputHistoryNavigation(host);
}
if (isChatBusy(host)) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
}
enqueueChatMessage(host, message, attachmentsToSend, refreshSessions);
return;
}

View File

@@ -529,6 +529,7 @@ describe("switchChatSession", () => {
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
resetChatInputHistoryNavigation: vi.fn(),
} as unknown as AppViewState;
refreshChatAvatarMock.mockResolvedValue(undefined);
@@ -541,6 +542,10 @@ describe("switchChatSession", () => {
expect(state.chatSideResult).toBeNull();
expect(state.chatSideResultTerminalRuns.size).toBe(0);
expect(
(state as unknown as { resetChatInputHistoryNavigation: ReturnType<typeof vi.fn> })
.resetChatInputHistoryNavigation,
).toHaveBeenCalled();
expect(refreshChatAvatarMock).toHaveBeenCalledWith(state);
expect(refreshSlashCommandsMock).toHaveBeenCalledWith({
client: undefined,
@@ -582,6 +587,7 @@ describe("switchChatSession", () => {
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
resetChatInputHistoryNavigation: vi.fn(),
client: { request: vi.fn() },
} as unknown as AppViewState;

View File

@@ -32,6 +32,7 @@ type SessionDefaultsSnapshot = {
type SessionSwitchHost = AppViewState & {
chatStreamStartedAt: number | null;
chatSideResultTerminalRuns: Set<string>;
resetChatInputHistoryNavigation(): void;
resetToolStream(): void;
resetChatScroll(): void;
};
@@ -84,6 +85,7 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
state.chatAvatarStatus = null;
state.chatAvatarReason = null;
state.chatQueue = [];
host.resetChatInputHistoryNavigation();
host.chatStreamStartedAt = null;
state.chatRunId = null;
host.chatSideResultTerminalRuns.clear();

View File

@@ -700,18 +700,18 @@ export function renderApp(state: AppViewState) {
if (!state.client || !state.connected) {
return null;
}
const payload = (await state.client.request("wiki.get", {
lookup,
fromLine: 1,
lineCount: 5000,
})) as {
const payload: {
title?: unknown;
path?: unknown;
content?: unknown;
updatedAt?: unknown;
totalLines?: unknown;
truncated?: unknown;
} | null;
} | null = await state.client.request("wiki.get", {
lookup,
fromLine: 1,
lineCount: 5000,
});
const title =
typeof payload?.title === "string" && payload.title.trim() ? payload.title.trim() : lookup;
const path =
@@ -1313,7 +1313,7 @@ export function renderApp(state: AppViewState) {
},
onSlashCommand: (cmd) => {
state.setTab("chat" as import("./navigation.ts").Tab);
state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `;
state.handleChatDraftChange(cmd.endsWith(" ") ? cmd : `${cmd} `);
},
})}
<div
@@ -1587,6 +1587,7 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetChatInputHistoryNavigation();
state.chatMessages = [];
state.chatToolMessages = [];
state.chatStream = null;
@@ -2361,8 +2362,9 @@ export function renderApp(state: AppViewState) {
},
onChatScroll: (event) => state.handleChatScroll(event),
getDraft: () => state.chatMessage,
onDraftChange: (next) => (state.chatMessage = next),
onDraftChange: (next) => state.handleChatDraftChange(next),
onRequestUpdate: requestHostUpdate,
onHistoryKeydown: (input) => state.handleChatInputHistoryKey(input),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),

View File

@@ -1,5 +1,6 @@
import type { EventLogEntry } from "./app-events.ts";
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts";
import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts";
import type { ChatSideResult } from "./chat/side-result.ts";
import type { CronModelSuggestionsState, CronState } from "./controllers/cron.ts";
@@ -106,6 +107,11 @@ export type AppViewState = {
chatModelsLoading: boolean;
chatModelCatalog: ModelCatalogEntry[];
chatQueue: ChatQueueItem[];
chatLocalInputHistoryBySession: Record<string, Array<{ text: string; ts: number }>>;
chatInputHistorySessionKey: string | null;
chatInputHistoryItems: string[] | null;
chatInputHistoryIndex: number;
chatDraftBeforeHistory: string | null;
realtimeTalkActive: boolean;
realtimeTalkStatus: RealtimeTalkStatus;
realtimeTalkDetail: string | null;
@@ -451,6 +457,9 @@ export type AppViewState = {
handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void;
setChatMessage: (next: string) => void;
handleChatDraftChange: (next: string) => void;
handleChatInputHistoryKey: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
resetChatInputHistoryNavigation: () => void;
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
toggleRealtimeTalk: () => Promise<void>;
steerQueuedChatMessage: (id: string) => Promise<void>;

View File

@@ -16,9 +16,14 @@ import {
} from "./app-channels.ts";
import {
handleAbortChat as handleAbortChatInternal,
handleChatDraftChange as handleChatDraftChangeInternal,
handleChatInputHistoryKey as handleChatInputHistoryKeyInternal,
handleSendChat as handleSendChatInternal,
removeQueuedMessage as removeQueuedMessageInternal,
resetChatInputHistoryNavigation as resetChatInputHistoryNavigationInternal,
steerQueuedChatMessage as steerQueuedChatMessageInternal,
type ChatInputHistoryKeyInput,
type ChatInputHistoryKeyResult,
} from "./app-chat.ts";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults.ts";
import type { EventLogEntry } from "./app-events.ts";
@@ -216,6 +221,11 @@ export class OpenClawApp extends LitElement {
@state() navDrawerOpen = false;
onSlashAction?: (action: string) => void;
chatLocalInputHistoryBySession: Record<string, Array<{ text: string; ts: number }>> = {};
chatInputHistorySessionKey: string | null = null;
chatInputHistoryItems: string[] | null = null;
@state() chatInputHistoryIndex = -1;
chatDraftBeforeHistory: string | null = null;
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@@ -778,6 +788,26 @@ export class OpenClawApp extends LitElement {
await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
}
handleChatDraftChange(next: string) {
handleChatDraftChangeInternal(
this as unknown as Parameters<typeof handleChatDraftChangeInternal>[0],
next,
);
}
handleChatInputHistoryKey(input: ChatInputHistoryKeyInput): ChatInputHistoryKeyResult {
return handleChatInputHistoryKeyInternal(
this as unknown as Parameters<typeof handleChatInputHistoryKeyInternal>[0],
input,
);
}
resetChatInputHistoryNavigation() {
resetChatInputHistoryNavigationInternal(
this as unknown as Parameters<typeof resetChatInputHistoryNavigationInternal>[0],
);
}
removeQueuedMessage(id: string) {
removeQueuedMessageInternal(
this as unknown as Parameters<typeof removeQueuedMessageInternal>[0],

View File

@@ -0,0 +1 @@
export const CHAT_HISTORY_RENDER_LIMIT = 200;

View File

@@ -1,49 +1,293 @@
const MAX = 50;
import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts";
import { extractText } from "./message-extract.ts";
export class InputHistory {
private items: string[] = [];
private cursor = -1;
type ChatLocalInputHistoryEntry = {
text: string;
ts: number;
};
push(text: string): void {
const trimmed = text.trim();
if (!trimmed) {
return;
export type ChatInputHistoryState = {
sessionKey: string;
chatLoading: boolean;
chatMessage: string;
chatMessages: unknown[];
chatLocalInputHistoryBySession: Record<string, ChatLocalInputHistoryEntry[]>;
chatInputHistorySessionKey: string | null;
chatInputHistoryItems: string[] | null;
chatInputHistoryIndex: number;
chatDraftBeforeHistory: string | null;
};
export type ChatInputHistoryKeyInput = {
key: "ArrowUp" | "ArrowDown";
selectionStart: number;
selectionEnd: number;
valueLength: number;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
isComposing: boolean;
keyCode: number;
};
export type ChatInputHistoryKeyResult = {
handled: boolean;
preventDefault: boolean;
restoreCaret: "up" | "down" | null;
decision:
| "blocked:history-loading"
| "blocked:modifier-or-composition"
| "blocked:selection-range"
| "blocked:arrowup-not-at-start"
| "blocked:arrowdown-editing-mode"
| "blocked:history-boundary"
| "handled:enter-history-up"
| "handled:history-up"
| "handled:history-down";
historyNavigationActiveBefore: boolean;
historyNavigationActiveAfter: boolean;
selectionStart: number;
selectionEnd: number;
valueLength: number;
};
function collectUserInputHistory(
messages: unknown[],
localEntries: ChatLocalInputHistoryEntry[],
): string[] {
if (messages.length === 0 && localEntries.length === 0) {
return [];
}
// Keep input recall aligned with what chat UI renders: only consider the visible history window.
const start = Math.max(0, messages.length - CHAT_HISTORY_RENDER_LIMIT);
const candidates: Array<{ text: string; ts: number }> = [...localEntries];
for (let i = messages.length - 1; i >= start; i--) {
const message = messages[i];
if (!message || typeof message !== "object") {
continue;
}
if (this.items[this.items.length - 1] === trimmed) {
return;
const entry = message as { role?: unknown };
const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
if (role !== "user") {
continue;
}
this.items.push(trimmed);
if (this.items.length > MAX) {
this.items.shift();
const text = extractText(message);
if (!text || !text.trim()) {
continue;
}
this.cursor = -1;
const timestamp =
typeof (message as { timestamp?: unknown }).timestamp === "number"
? ((message as { timestamp?: number }).timestamp ?? 0)
: 0;
candidates.push({ text, ts: timestamp });
}
up(): string | null {
if (this.items.length === 0) {
return null;
candidates.sort((a, b) => b.ts - a.ts);
const items: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (seen.has(candidate.text)) {
continue;
}
if (this.cursor < 0) {
this.cursor = this.items.length - 1;
} else if (this.cursor > 0) {
this.cursor--;
}
return this.items[this.cursor] ?? null;
}
down(): string | null {
if (this.cursor < 0) {
return null;
}
this.cursor++;
if (this.cursor >= this.items.length) {
this.cursor = -1;
return null;
}
return this.items[this.cursor] ?? null;
}
reset(): void {
this.cursor = -1;
seen.add(candidate.text);
items.push(candidate.text);
}
return items;
}
export function recordNonTranscriptInputHistory(state: ChatInputHistoryState, text: string) {
const trimmed = text.trim();
if (!trimmed) {
return;
}
const sessionEntries = state.chatLocalInputHistoryBySession[state.sessionKey] ?? [];
if (sessionEntries[0]?.text === trimmed) {
return;
}
state.chatLocalInputHistoryBySession[state.sessionKey] = [
{ text: trimmed, ts: Date.now() },
...sessionEntries,
].slice(0, CHAT_HISTORY_RENDER_LIMIT);
}
export function resetChatInputHistoryNavigation(state: ChatInputHistoryState) {
state.chatInputHistorySessionKey = null;
state.chatInputHistoryItems = null;
state.chatInputHistoryIndex = -1;
state.chatDraftBeforeHistory = null;
}
export function handleChatDraftChange(state: ChatInputHistoryState, next: string) {
state.chatMessage = next;
resetChatInputHistoryNavigation(state);
}
function hasStaleActiveHistorySelection(state: ChatInputHistoryState): boolean {
if (state.chatInputHistoryIndex === -1) {
return false;
}
if (
!Array.isArray(state.chatInputHistoryItems) ||
state.chatInputHistorySessionKey !== state.sessionKey
) {
return true;
}
const activeItem = state.chatInputHistoryItems[state.chatInputHistoryIndex];
return typeof activeItem !== "string" || activeItem !== state.chatMessage;
}
function ensureChatInputHistorySnapshot(state: ChatInputHistoryState): string[] {
if (
Array.isArray(state.chatInputHistoryItems) &&
state.chatInputHistorySessionKey === state.sessionKey
) {
return state.chatInputHistoryItems;
}
// Snapshot once per navigation round so incoming chat events don't shift arrow-key traversal order.
const items = collectUserInputHistory(
state.chatMessages,
state.chatLocalInputHistoryBySession[state.sessionKey] ?? [],
);
state.chatInputHistoryItems = items;
state.chatInputHistorySessionKey = state.sessionKey;
state.chatInputHistoryIndex = -1;
state.chatDraftBeforeHistory = state.chatMessage;
return items;
}
export function navigateChatInputHistory(
state: ChatInputHistoryState,
direction: "up" | "down",
): boolean {
const items = ensureChatInputHistorySnapshot(state);
if (items.length === 0) {
return false;
}
if (direction === "up") {
if (state.chatInputHistoryIndex >= items.length - 1) {
return false;
}
state.chatInputHistoryIndex += 1;
state.chatMessage = items[state.chatInputHistoryIndex] ?? state.chatMessage;
return true;
}
if (state.chatInputHistoryIndex === -1) {
return false;
}
if (state.chatInputHistoryIndex === 0) {
state.chatInputHistoryIndex = -1;
state.chatMessage = state.chatDraftBeforeHistory ?? "";
return true;
}
state.chatInputHistoryIndex -= 1;
state.chatMessage = items[state.chatInputHistoryIndex] ?? state.chatMessage;
return true;
}
export function handleChatInputHistoryKey(
state: ChatInputHistoryState,
input: ChatInputHistoryKeyInput,
): ChatInputHistoryKeyResult {
// Programmatic draft updates can bypass handleChatDraftChange(); if the current
// draft no longer matches the active recalled item, drop back to editing mode.
if (hasStaleActiveHistorySelection(state)) {
resetChatInputHistoryNavigation(state);
}
const historyNavigationActiveBefore = state.chatInputHistoryIndex !== -1;
const baseResult = {
historyNavigationActiveBefore,
historyNavigationActiveAfter: historyNavigationActiveBefore,
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
valueLength: input.valueLength,
};
if (state.chatLoading) {
return {
...baseResult,
handled: false,
preventDefault: false,
restoreCaret: null,
decision: "blocked:history-loading",
};
}
if (
input.altKey ||
input.ctrlKey ||
input.metaKey ||
input.shiftKey ||
input.isComposing ||
input.keyCode === 229
) {
return {
...baseResult,
handled: false,
preventDefault: false,
restoreCaret: null,
decision: "blocked:modifier-or-composition",
};
}
if (input.selectionStart !== input.selectionEnd) {
return {
...baseResult,
handled: false,
preventDefault: false,
restoreCaret: null,
decision: "blocked:selection-range",
};
}
if (historyNavigationActiveBefore) {
const direction = input.key === "ArrowUp" ? "up" : "down";
const navigated = navigateChatInputHistory(state, direction);
const historyNavigationActiveAfter = state.chatInputHistoryIndex !== -1;
return {
...baseResult,
handled: navigated,
preventDefault: navigated,
restoreCaret: navigated ? direction : null,
decision: navigated
? direction === "up"
? "handled:history-up"
: "handled:history-down"
: "blocked:history-boundary",
historyNavigationActiveAfter,
};
}
if (input.key === "ArrowDown") {
return {
...baseResult,
handled: false,
preventDefault: false,
restoreCaret: null,
decision: "blocked:arrowdown-editing-mode",
};
}
if (input.selectionStart !== 0) {
return {
...baseResult,
handled: false,
preventDefault: false,
restoreCaret: null,
decision: "blocked:arrowup-not-at-start",
};
}
const navigated = navigateChatInputHistory(state, "up");
const historyNavigationActiveAfter = state.chatInputHistoryIndex !== -1;
return {
...baseResult,
handled: navigated,
preventDefault: navigated,
restoreCaret: navigated ? "up" : null,
decision: navigated ? "handled:enter-history-up" : "blocked:history-boundary",
historyNavigationActiveAfter,
};
}

View File

@@ -348,6 +348,7 @@ export type ChatState = {
chatStream: string | null;
chatStreamStartedAt: number | null;
lastError: string | null;
resetChatInputHistoryNavigation?: () => void;
};
export type ChatEventPayload = {
@@ -378,6 +379,8 @@ export async function loadChatHistory(state: ChatState) {
const requestVersion = beginChatHistoryRequest(state);
const startedAt = Date.now();
const previousMessages = state.chatMessages;
// Any pending input-history snapshot becomes invalid once we start reloading transcript state.
state.resetChatInputHistoryNavigation?.();
state.chatLoading = true;
state.lastError = null;
try {

View File

@@ -18,7 +18,7 @@ import {
renderReadingIndicatorGroup,
renderStreamingGroup,
} from "../chat/grouped-render.ts";
import { InputHistory } from "../chat/input-history.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../chat/input-history.ts";
import { PinnedMessages } from "../chat/pinned-messages.ts";
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
import type { RealtimeTalkStatus } from "../chat/realtime-talk.ts";
@@ -100,6 +100,7 @@ export type ChatProps = {
getDraft?: () => string;
onDraftChange: (next: string) => void;
onRequestUpdate?: () => void;
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
onSend: () => void;
onCompact?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
@@ -124,15 +125,9 @@ export type ChatProps = {
basePath?: string;
};
// Persistent instances keyed by session
const inputHistories = new Map<string, InputHistory>();
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
function getInputHistory(sessionKey: string): InputHistory {
return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory());
}
function getPinnedMessages(sessionKey: string): PinnedMessages {
return getOrCreateSessionCacheValue(
pinnedMessagesMap,
@@ -201,6 +196,18 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
}
function restoreHistoryCaret(target: HTMLTextAreaElement, direction: "up" | "down") {
requestAnimationFrame(() => {
if (document.activeElement !== target) {
return;
}
adjustTextareaHeight(target);
const caret = direction === "up" ? 0 : target.value.length;
target.selectionStart = caret;
target.selectionEnd = caret;
});
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -726,7 +733,6 @@ export function renderChat(props: ChatProps) {
};
const pinned = getPinnedMessages(props.sessionKey);
const deleted = getDeletedMessages(props.sessionKey);
const inputHistory = getInputHistory(props.sessionKey);
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const tokens = tokenEstimate(props.draft);
@@ -971,20 +977,27 @@ export function renderChat(props: ChatProps) {
return;
}
// Input history (only when input is empty)
if (!props.draft.trim()) {
if (e.key === "ArrowUp") {
const prev = inputHistory.up();
if (prev !== null) {
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && props.onHistoryKeydown) {
const target = e.target as HTMLTextAreaElement;
const result = props.onHistoryKeydown({
key: e.key,
selectionStart: target.selectionStart,
selectionEnd: target.selectionEnd,
valueLength: target.value.length,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
isComposing: e.isComposing,
keyCode: e.keyCode,
});
if (result.handled) {
if (result.preventDefault) {
e.preventDefault();
props.onDraftChange(prev);
}
return;
}
if (e.key === "ArrowDown") {
const next = inputHistory.down();
e.preventDefault();
props.onDraftChange(next ?? "");
if (result.restoreCaret) {
restoreHistoryCaret(target, result.restoreCaret);
}
return;
}
}
@@ -1010,9 +1023,6 @@ export function renderChat(props: ChatProps) {
}
e.preventDefault();
if (canCompose) {
if (props.draft.trim()) {
inputHistory.push(props.draft);
}
props.onSend();
}
}
@@ -1022,7 +1032,6 @@ export function renderChat(props: ChatProps) {
const target = e.target as HTMLTextAreaElement;
adjustTextareaHeight(target);
updateSlashMenu(target.value, requestUpdate);
inputHistory.reset();
props.onDraftChange(target.value);
};
@@ -1246,7 +1255,7 @@ export function renderChat(props: ChatProps) {
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: props.onSend,
onStoreDraft: (draft) => inputHistory.push(draft),
onStoreDraft: () => {},
})}
</div>
</div>