diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 1c6734b91b1..7e659808194 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -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 { ({ handleSendChat, steerQueuedChatMessage, + navigateChatInputHistory, handleAbortChat, refreshChatAvatar, clearPendingQueueItemsForRun, @@ -44,7 +46,13 @@ function makeHost(overrides?: Partial): 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 () => { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 66f21d01283..f0e55a53c6c 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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[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; } diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 8687328d94c..94fc11b9087 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -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 }) + .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; diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 721016bf5ce..1bc8229d63a 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -32,6 +32,7 @@ type SessionDefaultsSnapshot = { type SessionSwitchHost = AppViewState & { chatStreamStartedAt: number | null; chatSideResultTerminalRuns: Set; + 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(); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 3853f935c3a..55693207d01 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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} `); }, })}
{ 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(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index af9d79ae47d..fee8e01b4ee 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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>; + 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; 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; toggleRealtimeTalk: () => Promise; steerQueuedChatMessage: (id: string) => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9e96a408d1e..26b1cafc0f5 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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> = {}; + 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[0]); } + handleChatDraftChange(next: string) { + handleChatDraftChangeInternal( + this as unknown as Parameters[0], + next, + ); + } + + handleChatInputHistoryKey(input: ChatInputHistoryKeyInput): ChatInputHistoryKeyResult { + return handleChatInputHistoryKeyInternal( + this as unknown as Parameters[0], + input, + ); + } + + resetChatInputHistoryNavigation() { + resetChatInputHistoryNavigationInternal( + this as unknown as Parameters[0], + ); + } + removeQueuedMessage(id: string) { removeQueuedMessageInternal( this as unknown as Parameters[0], diff --git a/ui/src/ui/chat/history-limits.ts b/ui/src/ui/chat/history-limits.ts new file mode 100644 index 00000000000..60108f92978 --- /dev/null +++ b/ui/src/ui/chat/history-limits.ts @@ -0,0 +1 @@ +export const CHAT_HISTORY_RENDER_LIMIT = 200; diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts index 34d8806d072..e3d9f59cf64 100644 --- a/ui/src/ui/chat/input-history.ts +++ b/ui/src/ui/chat/input-history.ts @@ -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; + 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(); + 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, + }; } diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 862183d3abf..b45fe6aa526 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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 { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 625fa8df636..2c39b1021be 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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; onToggleRealtimeTalk?: () => void; @@ -124,15 +125,9 @@ export type ChatProps = { basePath?: string; }; -// Persistent instances keyed by session -const inputHistories = new Map(); const pinnedMessagesMap = new Map(); const deletedMessagesMap = new Map(); -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: () => {}, })}