Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 69 additions & 38 deletions packages/app/e2e/regression/session-timeline-history-root.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 }[] = []
Expand Down Expand Up @@ -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<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[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<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
if (!viewport || !view) return []
return [...viewport.querySelectorAll<HTMLElement>("[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<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down
8 changes: 4 additions & 4 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -124,8 +126,6 @@ export function createPromptInputHistory(): PromptInputHistory {
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}

type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }

function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
Expand All @@ -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<PromptHistoryState>({ 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<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
Expand Down
99 changes: 99 additions & 0 deletions packages/app/src/components/prompt-input/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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({
Expand Down
Loading
Loading