Compare commits

...

1 Commits

Author SHA1 Message Date
Adam b2bd7be7ac fix(app): perf 2026-03-12 22:24:20 -05:00
5 changed files with 295 additions and 85 deletions
+181 -26
View File
@@ -1,6 +1,6 @@
import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createRoot, createSignal, onCleanup, untrack } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
@@ -50,8 +50,23 @@ export type FileContextItem = {
export type ContextItem = FileContextItem
type State = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Pics = {
synced: boolean
items: ImageAttachmentPart[]
}
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const PROMPT_SYNC_MS = 250
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
@@ -100,6 +115,76 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function cloneContextItem(item: ContextItem & { key: string }) {
return {
...item,
selection: cloneSelection(item.selection),
}
}
function cloneContextItems(items: (ContextItem & { key: string })[]) {
return items.map(cloneContextItem)
}
function cloneImages(items: ImageAttachmentPart[]) {
return items.map((item) => ({ ...item }))
}
function sameImages(a: ImageAttachmentPart[], b: ImageAttachmentPart[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
const x = a[i]
const y = b[i]
if (x.id !== y.id) return false
if (x.filename !== y.filename) return false
if (x.mime !== y.mime) return false
if (x.dataUrl !== y.dataUrl) return false
}
return true
}
function sameContext(a: (ContextItem & { key: string })[], b: (ContextItem & { key: string })[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
const x = a[i]
const y = b[i]
if (x.key !== y.key) return false
if (x.type !== y.type) return false
if (x.path !== y.path) return false
if (x.comment !== y.comment) return false
if (x.commentID !== y.commentID) return false
if (x.commentOrigin !== y.commentOrigin) return false
if (x.preview !== y.preview) return false
if (!isSelectionEqual(x.selection, y.selection)) return false
}
return true
}
function stripImages(prompt: Prompt): Prompt {
return prompt.filter((part) => part.type !== "image")
}
function pickImages(prompt: Prompt): ImageAttachmentPart[] {
return prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
}
function state(): State {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function images(): Pics {
return {
synced: false,
items: [],
}
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
@@ -120,15 +205,7 @@ function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>,
) {
function createPromptActions(setStore: SetStoreFunction<State>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
@@ -159,22 +236,100 @@ type PromptCacheEntry = {
function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}),
)
const [draft, setDraft, , draftReady] = persisted(Persist.scoped(dir, id, "prompt", [legacy]), createStore(state()))
const [pics, setPics, , picsReady] = persisted(Persist.scoped(dir, id, "prompt-image"), createStore(images()))
const [store, setStore] = createStore(state())
const [hydrated, setHydrated] = createSignal(false)
const prompt = createMemo(() => stripImages(store.prompt), clonePrompt(DEFAULT_PROMPT), {
equals: isPromptEqual,
})
const picsList = createMemo(() => pickImages(store.prompt), [] as ImageAttachmentPart[], {
equals: sameImages,
})
const ready = createMemo(() => hydrated() && draftReady() && picsReady())
let promptt: ReturnType<typeof setTimeout> | undefined
let pict: ReturnType<typeof setTimeout> | undefined
const clear = () => {
if (promptt !== undefined) clearTimeout(promptt)
if (pict !== undefined) clearTimeout(pict)
}
onCleanup(clear)
createEffect(() => {
if (!draftReady() || !picsReady()) return
if (hydrated()) return
const base = stripImages(draft.prompt)
const legacyPics = pickImages(draft.prompt)
const nextPics = pics.synced || pics.items.length > 0 ? pics.items : legacyPics
batch(() => {
setStore({
prompt: clonePrompt([...base, ...nextPics]),
cursor: draft.cursor,
context: {
items: cloneContextItems(draft.context.items),
},
})
setHydrated(true)
})
if (!pics.synced && legacyPics.length > 0) {
setPics({ synced: true, items: cloneImages(legacyPics) })
}
})
createEffect(() => {
if (!hydrated()) return
prompt()
store.cursor
store.context.items
if (promptt !== undefined) clearTimeout(promptt)
promptt = setTimeout(() => {
promptt = undefined
const next = clonePrompt(prompt())
const items = cloneContextItems(store.context.items)
const samePrompt = isPromptEqual(stripImages(untrack(() => draft.prompt)), next)
const sameCursor = untrack(() => draft.cursor) === store.cursor
const sameItems = sameContext(
untrack(() => draft.context.items),
items,
)
if (samePrompt && sameCursor && sameItems) return
batch(() => {
setDraft("prompt", next)
setDraft("cursor", store.cursor)
setDraft("context", "items", items)
})
}, PROMPT_SYNC_MS)
})
createEffect(() => {
if (!hydrated()) return
picsList()
if (pict !== undefined) clearTimeout(pict)
pict = setTimeout(() => {
pict = undefined
const next = cloneImages(picsList())
if (
sameImages(
untrack(() => pics.items),
next,
)
)
if (untrack(() => pics.synced)) return
setPics({ synced: true, items: next })
}, PROMPT_SYNC_MS)
})
const actions = createPromptActions(setStore)
+5 -5
View File
@@ -670,14 +670,14 @@ export default function Layout(props: ParentProps) {
running: number
}
const prefetchChunk = 200
const prefetchConcurrency = 2
const prefetchPendingLimit = 10
const span = 4
const prefetchChunk = 80
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const span = 1
const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>()
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
const PREFETCH_MAX_SESSIONS_PER_DIR = 6
const prefetchedByDir = new Map<string, Set<string>>()
const lruFor = (directory: string) => {
@@ -261,12 +261,11 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hoverPrefetch.current = undefined
}
const scheduleHoverPrefetch = () => {
warm(1, "high")
if (hoverPrefetch.current !== undefined) return
hoverPrefetch.current = setTimeout(() => {
hoverPrefetch.current = undefined
warm(2, "low")
}, 80)
warm(1, "low")
}, 160)
}
onCleanup(cancelHoverPrefetch)
@@ -291,8 +290,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened}
warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")}
warmPress={() => warm(1, "high")}
warmFocus={() => warm(1, "high")}
cancelHoverPrefetch={cancelHoverPrefetch}
/>
)
+104 -48
View File
@@ -526,11 +526,58 @@ export default function Page() {
return key
}, sessionKey())
type Job = {
frame?: number
timer?: number
idle?: number
}
const syncMs = SESSION_PREFETCH_TTL
const todoMs = 30_000
const diffMs = 30_000
const at = {
sync: new Map<string, number>(),
todo: new Map<string, number>(),
diff: new Map<string, number>(),
}
const key = (dir: string, id: string) => `${dir}\n${id}`
const due = (map: Map<string, number>, id: string, ttl: number) => Date.now() - (map.get(id) ?? 0) >= ttl
const touch = (map: Map<string, number>, id: string) => map.set(id, Date.now())
const clearJob = (job: Job) => {
if (job.frame !== undefined) cancelAnimationFrame(job.frame)
if (job.timer !== undefined) window.clearTimeout(job.timer)
if (job.idle !== undefined && typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(job.idle)
}
job.frame = undefined
job.timer = undefined
job.idle = undefined
}
const queueJob = (job: Job, run: VoidFunction, delay = 180) => {
clearJob(job)
job.frame = requestAnimationFrame(() => {
job.frame = undefined
job.timer = window.setTimeout(() => {
job.timer = undefined
const fire = () => {
job.idle = undefined
run()
}
if (typeof window.requestIdleCallback !== "function") {
fire()
return
}
job.idle = window.requestIdleCallback(fire, { timeout: 500 })
}, delay)
})
}
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const refreshJob: Job = {}
const diffJob: Job = {}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -690,17 +737,16 @@ export default function Page() {
createEffect(
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined
refreshTimer = undefined
clearJob(refreshJob)
if (!id) return
const dir = sdk.directory
const idKey = key(dir, id)
const cached = untrack(() => sync.data.message[id] !== undefined)
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(sdk.directory, id)
const info = getSessionPrefetch(dir, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
@@ -710,17 +756,31 @@ export default function Page() {
void sync.session.sync(id)
})
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
queueJob(
refreshJob,
() => {
if (params.id !== id) return
if (sdk.directory !== dir) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
if (!todos) {
touch(at.todo, idKey)
void sync.session.todo(id)
}
if (stale && due(at.sync, idKey, syncMs)) {
touch(at.sync, idKey)
void sync.session.sync(id, { force: true })
}
if (todos && due(at.todo, idKey, todoMs)) {
touch(at.todo, idKey)
void sync.session.todo(id, { force: true })
}
})
}, 0)
})
},
cached ? 220 : 450,
)
}),
)
@@ -1111,20 +1171,6 @@ export default function Page() {
requestAnimationFrame(() => attempt(0))
})
createEffect(() => {
const id = params.id
if (!id) return
const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
void sync.session.diff(id)
})
createEffect(
on(
() =>
@@ -1133,26 +1179,38 @@ export default function Page() {
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
sync.status,
] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
diffFrame = undefined
diffTimer = undefined
([session, wants, status]) => {
clearJob(diffJob)
if (!wants) return
const id = params.id
if (!id) return
if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
if (status === "loading") return
diffFrame = requestAnimationFrame(() => {
diffFrame = undefined
diffTimer = window.setTimeout(() => {
diffTimer = undefined
if (sessionKey() !== key) return
const dir = sdk.directory
const idKey = key(dir, id)
const cached = untrack(() => sync.data.session_diff[id] !== undefined)
queueJob(
diffJob,
() => {
if (sessionKey() !== session) return
if (sdk.directory !== dir) return
if (!cached) {
touch(at.diff, idKey)
void sync.session.diff(id)
return
}
if (!due(at.diff, idKey, diffMs)) return
touch(at.diff, idKey)
void sync.session.diff(id, { force: true })
}, 0)
})
},
cached ? 240 : 160,
)
},
{ defer: true },
),
@@ -1640,10 +1698,8 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
clearJob(refreshJob)
clearJob(diffJob)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
@@ -228,7 +228,6 @@ export function MessageTimeline(props: {
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
const id = sessionID()
@@ -315,6 +314,7 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
const rendered = createMemo(() => staging.messages().map((message) => message.id))
const [title, setTitle] = createStore({
draft: "",