fix(app): perf
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user