mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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],
|
||||
|
||||
1
ui/src/ui/chat/history-limits.ts
Normal file
1
ui/src/ui/chat/history-limits.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CHAT_HISTORY_RENDER_LIMIT = 200;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user