diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 15375cafed59..5de7d389f241 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" -const assistants = Array.from({ length: 14 }, (_, index) => +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, parentID: userID, created: 1700000001000 + index * 1_000, - completed: index < 13, + completed: index < initialPageSize, }), ) const messages = [userMessage(), ...assistants] @@ -46,7 +48,7 @@ const scenarios = [ test.use({ viewport: { width: 646, height: 1385 } }) for (const scenario of scenarios) { - test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => { + test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { const requests: { before?: string; phase: "start" | "end" }[] = [] const pages: { before?: string; limit: number }[] = [] const roots: { sessionID: string; messageID: string }[] = [] @@ -101,36 +103,60 @@ for (const scenario of scenarios) { } }, }) - await page.addInitScript( - ({ userPartID, lastPartID }) => { - const state = { armed: false, hidden: false, samples: 0, stop: false } - ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state - const sample = () => { - if (state.armed) { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - const visible = (partID: string) => { - const part = viewport?.querySelector(`[data-timeline-part-id="${partID}"]`) - const rect = part?.getBoundingClientRect() - return ( - !!rect && - !!view && - rect.width > 0 && - rect.height > 0 && - rect.bottom > view.top && - rect.top < view.bottom - ) - } - if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true - state.samples++ + await page.addInitScript(() => { + const visibleParts = () => { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + if (!viewport || !view) return [] + return [...viewport.querySelectorAll("[data-timeline-part-id]")] + .filter((part) => { + const rect = part.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) + } + const state = { + armed: false, + hidden: false, + visibleParts: [] as string[], + samples: 0, + stop: false, + arm() { + state.visibleParts = visibleParts() + state.armed = true + }, + } + ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state + const sample = () => { + if (state.armed) { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && + !!view && + rect.width > 0 && + rect.height > 0 && + rect.bottom > view.top && + rect.top < view.bottom + ) } - if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + if ( + !virtual || + state.visibleParts.length === 0 || + state.visibleParts.some((partID) => !visible(partID)) + ) + state.hidden = true + state.samples++ } - requestAnimationFrame(() => setTimeout(sample, 0)) - }, - { userPartID, lastPartID }, - ) + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() @@ -143,23 +169,28 @@ for (const scenario of scenarios) { "messages:start:latest", "messages:end:latest", `message:${userID}`, - `messages:start:${messages.at(-2)!.info.id}`, + `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { - __historyRootProbe?: { armed: boolean } + __historyRootProbe?: { arm(): void } } - ).__historyRootProbe!.armed = true + ).__historyRootProbe!.arm() }) await waitForProbeSamples(page, 0) - expect(await historyRootHidden(page)).toBe(false) + expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) - expect(pages[0]).toEqual({ before: undefined, limit: 2 }) + expect(pages).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) @@ -213,7 +244,7 @@ async function waitForProbeSamples(page: Page, after: number) { ) } -function historyRootHidden(page: Page) { +function visibleContentHidden(page: Page) { return page.evaluate( () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, ) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bbb71e87224a..379e5a29b078 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -62,8 +62,10 @@ import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, + type PromptHistoryState, type PromptHistoryStoredEntry, promptLength, + sanitizePromptHistoryState, } from "./prompt-input/history" import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" @@ -124,8 +126,6 @@ export function createPromptInputHistory(): PromptInputHistory { return createPromptInputHistoryStore(normal, setNormal, shell, setShell) } -type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } - function createPromptInputHistoryStore( normal: Store, setNormal: SetStoreFunction, @@ -146,11 +146,11 @@ function createPromptInputHistoryStore( function createPersistedPromptInputHistory() { const [normal, setNormal] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), + { ...Persist.global("prompt-history", ["prompt-history.v1"]), migrate: sanitizePromptHistoryState }, createStore({ entries: [] }), ) const [shell, setShell] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + { ...Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), migrate: sanitizePromptHistoryState }, createStore({ entries: [] }), ) return createPromptInputHistoryStore(normal, setNormal, shell, setShell) diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 5e9c2c66eadc..0f9862389247 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -3,16 +3,24 @@ import type { Prompt } from "@/context/prompt" import { canNavigateHistoryAtCursor, clonePromptParts, + MAX_HISTORY_INLINE_DATA_URL_LENGTH, normalizePromptHistoryEntry, navigatePromptHistory, prependHistoryEntry, promptLength, + sanitizePromptHistoryState, type PromptHistoryComment, + type PromptHistoryEntry, + type PromptHistoryStoredEntry, } from "./history" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] +const oversizedDataUrl = (mime = "application/pdf") => { + const prefix = `data:${mime};base64,` + return prefix + "A".repeat(MAX_HISTORY_INLINE_DATA_URL_LENGTH - prefix.length + 1) +} const comment = (id: string, value = "note"): PromptHistoryComment => ({ id, path: "src/a.ts", @@ -23,6 +31,18 @@ const comment = (id: string, value = "note"): PromptHistoryComment => ({ preview: "const a = 1", }) +function firstHistoryEntry(entries: PromptHistoryStoredEntry[]) { + const entry = entries[0] + if (!entry) throw new Error("expected history entry") + return entry +} + +function expectHistoryState(value: unknown): asserts value is { entries: PromptHistoryEntry[] } { + if (!value || typeof value !== "object" || !("entries" in value) || !Array.isArray(value.entries)) { + throw new Error("expected history state") + } +} + describe("prompt-input history", () => { test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { const first = prependHistoryEntry([], DEFAULT_PROMPT) @@ -41,6 +61,85 @@ describe("prompt-input history", () => { expect(dedupedComments).toBe(commentsOnly) }) + test("prependHistoryEntry skips oversized inline data-url attachments while keeping text", () => { + const added = prependHistoryEntry([], [ + { type: "text", content: "review this", start: 0, end: 11 }, + { + type: "image", + id: "pdf_1", + filename: "paper.pdf", + mime: "application/pdf", + dataUrl: oversizedDataUrl(), + }, + ]) + const entry = normalizePromptHistoryEntry(firstHistoryEntry(added)) + + expect(entry.prompt).toEqual([{ type: "text", content: "review this", start: 0, end: 11 }]) + }) + + test("prependHistoryEntry keeps small inline data-url attachments", () => { + const added = prependHistoryEntry([], [ + { type: "text", content: "see image", start: 0, end: 9 }, + { + type: "image", + id: "img_1", + filename: "image.png", + mime: "image/png", + dataUrl: "data:image/png;base64,AAA", + }, + ]) + const entry = normalizePromptHistoryEntry(firstHistoryEntry(added)) + + expect(entry.prompt).toMatchObject([ + { type: "text", content: "see image" }, + { type: "image", filename: "image.png", dataUrl: "data:image/png;base64,AAA" }, + ]) + }) + + test("sanitizePromptHistoryState removes oversized persisted data urls", () => { + const migrated = sanitizePromptHistoryState({ + entries: [ + { + prompt: [ + { type: "text", content: "old entry", start: 0, end: 9 }, + { + type: "image", + id: "pdf_1", + filename: "paper.pdf", + mime: "application/pdf", + dataUrl: oversizedDataUrl(), + }, + { + type: "file", + path: "paper.pdf", + content: "@paper.pdf", + start: 10, + end: 20, + url: oversizedDataUrl(), + }, + ], + comments: [comment("c1")], + }, + ], + }) + expectHistoryState(migrated) + + expect(migrated.entries).toHaveLength(1) + expect(migrated.entries[0]?.comments).toEqual([comment("c1")]) + expect(migrated.entries[0]?.prompt).toEqual([ + { type: "text", content: "old entry", start: 0, end: 9 }, + { + type: "file", + path: "paper.pdf", + content: "@paper.pdf", + start: 10, + end: 20, + selection: undefined, + url: undefined, + }, + ]) + }) + test("navigatePromptHistory restores saved prompt when moving down from newest", () => { const entries = [text("third"), text("second"), text("first")] const up = navigatePromptHistory({ diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 79e8abc0d9eb..41ace354d5ce 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -4,6 +4,7 @@ import type { SelectedLineRange } from "@/context/file" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] export const MAX_HISTORY = 100 +export const MAX_HISTORY_INLINE_DATA_URL_LENGTH = 64 * 1024 export type PromptHistoryComment = { id: string @@ -21,6 +22,7 @@ export type PromptHistoryEntry = { } export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry +export type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { const position = Math.max(0, Math.min(cursor, text.length)) @@ -43,6 +45,38 @@ export function clonePromptParts(prompt: Prompt): Prompt { }) } +function isInlineDataUrl(value: string | undefined): value is string { + return value?.startsWith("data:") === true +} + +export function sanitizePromptForHistory(prompt: Prompt): Prompt { + const result: Prompt = [] + let remainingDataUrlLength = MAX_HISTORY_INLINE_DATA_URL_LENGTH + const keepInlineDataUrl = (value: string | undefined) => { + if (!isInlineDataUrl(value)) return true + if (value.length > remainingDataUrlLength) return false + remainingDataUrlLength -= value.length + return true + } + + for (const part of prompt) { + if (part.type === "text" || part.type === "agent") { + result.push({ ...part }) + continue + } + if (part.type === "image") { + if (keepInlineDataUrl(part.dataUrl)) result.push({ ...part }) + continue + } + result.push({ + ...part, + selection: part.selection ? { ...part.selection } : undefined, + url: keepInlineDataUrl(part.url) ? part.url : undefined, + }) + } + return result +} + function cloneSelection(selection: SelectedLineRange): SelectedLineRange { return { start: selection.start, @@ -72,6 +106,54 @@ export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): Pr } } +export function sanitizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry { + const normalized = normalizePromptHistoryEntry(entry) + return { + prompt: sanitizePromptForHistory(normalized.prompt), + comments: clonePromptHistoryComments(normalized.comments), + } +} + +function hasHistoryContent(entry: PromptHistoryEntry) { + const text = entry.prompt + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim() + const hasImages = entry.prompt.some((part) => part.type === "image") + const hasComments = entry.comments.some((comment) => !!comment.comment.trim()) + return !!text || hasImages || hasComments +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isPromptHistoryStoredEntry(value: unknown): value is PromptHistoryStoredEntry { + if (Array.isArray(value)) return true + return isRecord(value) && Array.isArray(value.prompt) && Array.isArray(value.comments) +} + +function sanitizePromptHistoryEntries(entries: unknown[]) { + return entries.flatMap((entry) => { + if (!isPromptHistoryStoredEntry(entry)) return [] + try { + const sanitized = sanitizePromptHistoryEntry(entry) + return hasHistoryContent(sanitized) ? [sanitized] : [] + } catch { + return [] + } + }) +} + +export function sanitizePromptHistoryState(value: unknown): unknown { + if (Array.isArray(value)) return { entries: sanitizePromptHistoryEntries(value) } + if (!isRecord(value) || !Array.isArray(value.entries)) return value + return { + ...value, + entries: sanitizePromptHistoryEntries(value.entries), + } +} + export function promptLength(prompt: Prompt) { return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) } @@ -82,16 +164,17 @@ export function prependHistoryEntry( comments: PromptHistoryComment[] = [], max = MAX_HISTORY, ) { + const sanitizedPrompt = sanitizePromptForHistory(prompt) const text = prompt .map((part) => ("content" in part ? part.content : "")) .join("") .trim() - const hasImages = prompt.some((part) => part.type === "image") + const hasImages = sanitizedPrompt.some((part) => part.type === "image") const hasComments = comments.some((comment) => !!comment.comment.trim()) if (!text && !hasImages && !hasComments) return entries const entry = { - prompt: clonePromptParts(prompt), + prompt: sanitizedPrompt, comments: clonePromptHistoryComments(comments), } satisfies PromptHistoryEntry const last = entries[0] diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index a12fd832fd1c..6b5962bc641c 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -189,6 +189,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setStore("current", undefined) return } + if (item.name === agent.current()?.name) return batch(() => { setStore("current", item.name) diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 46a0dc826658..38f7ff71e556 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -174,7 +174,7 @@ describe("server session", () => { await ctx.store.sync("root") expect(ctx.get).toEqual([{ sessionID: "root" }]) - expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }]) + expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }]) expect(ctx.store.data.message.root).toEqual([]) }) @@ -194,7 +194,7 @@ describe("server session", () => { await store.sync("child") - expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }]) + expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.history.more("child")).toBe(true) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 6898cc230497..a440aab38f0b 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) -const initialMessagePageSize = 2 +const initialMessagePageSize = 20 const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts index a71d58ff0723..f6769d84d677 100644 --- a/packages/app/src/pages/home-session-open.test.ts +++ b/packages/app/src/pages/home-session-open.test.ts @@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test" import { shouldOpenSessionInBackground } from "./home-session-open" describe("shouldOpenSessionInBackground", () => { + test("opens middle clicks in the background", () => { + expect(shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false })).toBe( + true, + ) + expect(shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false })).toBe( + false, + ) + }) + test("requires only the platform primary modifier", () => { - expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) - expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) - expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) - expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) - expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) + expect(shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe( + true, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe( + true, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe( + false, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe( + false, + ) + expect(shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe( + false, + ) }) }) diff --git a/packages/app/src/pages/home-session-open.ts b/packages/app/src/pages/home-session-open.ts index 8e32efb559d2..9f117935b77e 100644 --- a/packages/app/src/pages/home-session-open.ts +++ b/packages/app/src/pages/home-session-open.ts @@ -1,10 +1,13 @@ export function shouldOpenSessionInBackground(input: { + button: number mac: boolean meta: boolean ctrl: boolean shift: boolean alt: boolean }) { + if (input.button === 1) return true + if (input.button !== 0) return false if (input.shift || input.alt) return false if (input.mac) return input.meta && !input.ctrl return input.ctrl && !input.meta diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672dfca..a60ec2977cd8 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -240,10 +240,11 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } } -// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the -// background without navigating, matching browser conventions. +// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session +// tab in the background without navigating, matching browser conventions. function isBackgroundOpen(event: MouseEvent) { return shouldOpenSessionInBackground({ + button: event.button, mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), meta: event.metaKey, ctrl: event.ctrlKey, @@ -1386,7 +1387,15 @@ function HomeSessionSearchResultRow(props: { group: !!showProjectName(), }} onMouseEnter={() => props.onHighlight()} + onMouseDown={(event) => { + if (event.button === 1) event.preventDefault() + }} onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })} + onAuxClick={(event) => { + if (!isBackgroundOpen(event)) return + event.preventDefault() + props.onSelect(props.record.session, { background: true }) + }} > { + if (event.button === 1) event.preventDefault() + }} onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })} + onAuxClick={(event) => { + if (!isBackgroundOpen(event)) return + event.preventDefault() + props.openSession(props.record.session, { background: true }) + }} >