diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 13d9d147e25..3caaf99fef7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -108,18 +108,16 @@ export function AppInterface() { } /> ( - - - - - }> - - - - - - + component={() => ( + + + + }> + + + + + )} /> diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 050262ae629..2cc0d62de76 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { FileContent } from "@opencode-ai/sdk/v2" @@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { } } +const WORKSPACE_KEY = "__workspace__" +const MAX_FILE_VIEW_SESSIONS = 20 +const MAX_VIEW_FILES = 500 + +type ViewSession = ReturnType + +type ViewCacheEntry = { + value: ViewSession + dispose: VoidFunction +} + +function createViewSession(dir: string, id: string | undefined) { + const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` + + const [view, setView, _, ready] = persisted( + Persist.scoped(dir, id, "file-view", [legacyViewKey]), + createStore<{ + file: Record + }>({ + file: {}, + }), + ) + + const meta = { pruned: false } + + const pruneView = (keep?: string) => { + const keys = Object.keys(view.file) + if (keys.length <= MAX_VIEW_FILES) return + + const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) + if (drop.length === 0) return + + setView( + produce((draft) => { + for (const key of drop) { + delete draft.file[key] + } + }), + ) + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + pruneView() + }) + + const scrollTop = (path: string) => view.file[path]?.scrollTop + const scrollLeft = (path: string) => view.file[path]?.scrollLeft + const selectedLines = (path: string) => view.file[path]?.selectedLines + + const setScrollTop = (path: string, top: number) => { + setView("file", path, (current) => { + if (current?.scrollTop === top) return current + return { + ...(current ?? {}), + scrollTop: top, + } + }) + pruneView(path) + } + + const setScrollLeft = (path: string, left: number) => { + setView("file", path, (current) => { + if (current?.scrollLeft === left) return current + return { + ...(current ?? {}), + scrollLeft: left, + } + }) + pruneView(path) + } + + const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const next = range ? normalizeSelectedLines(range) : null + setView("file", path, (current) => { + if (current?.selectedLines === next) return current + return { + ...(current ?? {}), + selectedLines: next, + } + }) + pruneView(path) + } + + return { + ready, + scrollTop, + scrollLeft, + selectedLines, + setScrollTop, + setScrollLeft, + setSelectedLines, + } +} + export const { use: useFile, provider: FileProvider } = createSimpleContext({ name: "File", + gate: false, init: () => { const sdk = useSDK() const sync = useSync() @@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ file: {}, }) - const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) + const viewCache = new Map() - const [view, setView, _, ready] = persisted( - Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]), - createStore<{ - file: Record - }>({ - file: {}, - }), - ) + const disposeViews = () => { + for (const entry of viewCache.values()) { + entry.dispose() + } + viewCache.clear() + } - const MAX_VIEW_FILES = 500 - const viewMeta = { pruned: false } + const pruneViews = () => { + while (viewCache.size > MAX_FILE_VIEW_SESSIONS) { + const first = viewCache.keys().next().value + if (!first) return + const entry = viewCache.get(first) + entry?.dispose() + viewCache.delete(first) + } + } - const pruneView = (keep?: string) => { - const keys = Object.keys(view.file) - if (keys.length <= MAX_VIEW_FILES) return + const loadView = (dir: string, id: string | undefined) => { + const key = `${dir}:${id ?? WORKSPACE_KEY}` + const existing = viewCache.get(key) + if (existing) { + viewCache.delete(key) + viewCache.set(key, existing) + return existing.value + } - const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) - if (drop.length === 0) return + const entry = createRoot((dispose) => ({ + value: createViewSession(dir, id), + dispose, + })) - setView( - produce((draft) => { - for (const key of drop) { - delete draft.file[key] - } - }), - ) + viewCache.set(key, entry) + pruneViews() + return entry.value } - createEffect(() => { - if (!ready()) return - if (viewMeta.pruned) return - viewMeta.pruned = true - pruneView() - }) + const view = createMemo(() => loadView(params.dir!, params.id)) function ensure(path: string) { if (!path) return @@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const get = (input: string) => store.file[normalize(input)] - const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop - const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft - const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines + const scrollTop = (input: string) => view().scrollTop(normalize(input)) + const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) + const selectedLines = (input: string) => view().selectedLines(normalize(input)) const setScrollTop = (input: string, top: number) => { const path = normalize(input) - setView("file", path, (current) => { - if (current?.scrollTop === top) return current - return { - ...(current ?? {}), - scrollTop: top, - } - }) - pruneView(path) + view().setScrollTop(path, top) } const setScrollLeft = (input: string, left: number) => { const path = normalize(input) - setView("file", path, (current) => { - if (current?.scrollLeft === left) return current - return { - ...(current ?? {}), - scrollLeft: left, - } - }) - pruneView(path) + view().setScrollLeft(path, left) } const setSelectedLines = (input: string, range: SelectedLineRange | null) => { const path = normalize(input) - const next = range ? normalizeSelectedLines(range) : null - setView("file", path, (current) => { - if (current?.selectedLines === next) return current - return { - ...(current ?? {}), - selectedLines: next, - } - }) - pruneView(path) + view().setSelectedLines(path, range) } - onCleanup(() => stop()) + onCleanup(() => { + stop() + disposeViews() + }) return { - ready, + ready: () => view().ready(), normalize, tab, pathFromTab, diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 2fa4571e890..993d7e7a89a 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createMemo } from "solid-js" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" @@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt { return prompt.map(clonePart) } +const WORKSPACE_KEY = "__workspace__" +const MAX_PROMPT_SESSIONS = 20 + +type PromptSession = ReturnType + +type PromptCacheEntry = { + value: PromptSession + dispose: VoidFunction +} + +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: { + activeTab: boolean + items: (ContextItem & { key: string })[] + } + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + context: { + activeTab: true, + items: [], + }, + }), + ) + + function keyForItem(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + return `${item.type}:${item.path}:${start}:${end}` + } + + return { + ready, + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + context: { + activeTab: createMemo(() => store.context.activeTab), + items: createMemo(() => store.context.items), + addActive() { + setStore("context", "activeTab", true) + }, + removeActive() { + setStore("context", "activeTab", false) + }, + add(item: ContextItem) { + const key = keyForItem(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + }, + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ name: "Prompt", + gate: false, init: () => { const params = useParams() - const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) - - const [store, setStore, _, ready] = persisted( - Persist.scoped(params.dir!, params.id, "prompt", [legacy()]), - createStore<{ - prompt: Prompt - cursor?: number - context: { - activeTab: boolean - items: (ContextItem & { key: string })[] - } - }>({ - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - context: { - activeTab: true, - items: [], - }, - }), - ) - - function keyForItem(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - return `${item.type}:${item.path}:${start}:${end}` + const cache = new Map() + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_PROMPT_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const load = (dir: string, id: string | undefined) => { + const key = `${dir}:${id ?? WORKSPACE_KEY}` + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createPromptSession(dir, id), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value } + const session = createMemo(() => load(params.dir!, params.id)) + return { - ready, - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + ready: () => session().ready(), + current: () => session().current(), + cursor: () => session().cursor(), + dirty: () => session().dirty(), context: { - activeTab: createMemo(() => store.context.activeTab), - items: createMemo(() => store.context.items), - addActive() { - setStore("context", "activeTab", true) - }, - removeActive() { - setStore("context", "activeTab", false) - }, - add(item: ContextItem) { - const key = keyForItem(item) - if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) - }, - remove(key: string) { - setStore("context", "items", (items) => items.filter((x) => x.key !== key)) - }, - }, - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) + activeTab: () => session().context.activeTab(), + items: () => session().context.items(), + addActive: () => session().context.addActive(), + removeActive: () => session().context.removeActive(), + add: (item: ContextItem) => session().context.add(item), + remove: (key: string) => session().context.remove(key), }, + set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), + reset: () => session().reset(), } }, }) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index a237871f984..e5f2c076ed8 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,5 +1,5 @@ import { batch, createMemo } from "solid-js" -import { produce, reconcile } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -14,6 +14,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const [store, setStore] = globalSync.child(sdk.directory) const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") + const chunk = 200 + const inflight = new Map>() + const inflightDiff = new Map>() + const inflightTodo = new Map>() + const [meta, setMeta] = createStore({ + limit: {} as Record, + complete: {} as Record, + loading: {} as Record, + }) + + const getSession = (sessionID: string) => { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + } + + const limitFor = (count: number) => { + if (count <= chunk) return chunk + return Math.ceil(count / chunk) * chunk + } + + const hydrateMessages = (sessionID: string) => { + if (meta.limit[sessionID] !== undefined) return + + const messages = store.message[sessionID] + if (!messages) return + + const limit = limitFor(messages.length) + setMeta("limit", sessionID, limit) + setMeta("complete", sessionID, messages.length < limit) + } + + const loadMessages = async (sessionID: string, limit: number) => { + if (meta.loading[sessionID]) return + + setMeta("loading", sessionID, true) + await retry(() => sdk.client.session.messages({ sessionID, limit })) + .then((messages) => { + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const next = items + .map((x) => x.info) + .filter((m) => !!m?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + + batch(() => { + setStore("message", sessionID, reconcile(next, { key: "id" })) + + for (const message of items) { + setStore( + "part", + message.info.id, + reconcile( + message.parts + .filter((p) => !!p?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + + setMeta("limit", sessionID, limit) + setMeta("complete", sessionID, next.length < limit) + }) + }) + .finally(() => { + setMeta("loading", sessionID, false) + }) + } return { data: store, @@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return undefined }, session: { - get(sessionID: string) { - const match = Binary.search(store.session, sessionID, (s) => s.id) - if (match.found) return store.session[match.index] - return undefined - }, + get: getSession, addOptimisticMessage(input: { sessionID: string messageID: string @@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, - async sync(sessionID: string, _isRetry = false) { - const [session, messages, todo, diff] = await Promise.all([ - retry(() => sdk.client.session.get({ sessionID })), - retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })), - retry(() => sdk.client.session.todo({ sessionID })), - retry(() => sdk.client.session.diff({ sessionID })), - ]) + async sync(sessionID: string) { + const hasSession = getSession(sessionID) !== undefined + hydrateMessages(sessionID) - batch(() => { - setStore( - "session", - produce((draft) => { - const match = Binary.search(draft, sessionID, (s) => s.id) - if (match.found) { - draft[match.index] = session.data! - return - } - draft.splice(match.index, 0, session.data!) - }), - ) - - setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) - setStore( - "message", - sessionID, - reconcile( - (messages.data ?? []) - .map((x) => x.info) - .filter((m) => !!m?.id) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) - - for (const message of messages.data ?? []) { - if (!message?.info?.id) continue - setStore( - "part", - message.info.id, - reconcile( - message.parts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) - } + const hasMessages = store.message[sessionID] !== undefined + if (hasSession && hasMessages) return - setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) - }) + const pending = inflight.get(sessionID) + if (pending) return pending + + const limit = meta.limit[sessionID] ?? chunk + + const sessionReq = hasSession + ? Promise.resolve() + : retry(() => sdk.client.session.get({ sessionID })).then((session) => { + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit) + + const promise = Promise.all([sessionReq, messagesReq]) + .then(() => {}) + .finally(() => { + inflight.delete(sessionID) + }) + + inflight.set(sessionID, promise) + return promise + }, + async diff(sessionID: string) { + if (store.session_diff[sessionID] !== undefined) return + + const pending = inflightDiff.get(sessionID) + if (pending) return pending + + const promise = retry(() => sdk.client.session.diff({ sessionID })) + .then((diff) => { + setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) + }) + .finally(() => { + inflightDiff.delete(sessionID) + }) + + inflightDiff.set(sessionID, promise) + return promise + }, + async todo(sessionID: string) { + if (store.todo[sessionID] !== undefined) return + + const pending = inflightTodo.get(sessionID) + if (pending) return pending + + const promise = retry(() => sdk.client.session.todo({ sessionID })) + .then((todo) => { + setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) + }) + .finally(() => { + inflightTodo.delete(sessionID) + }) + + inflightTodo.set(sessionID, promise) + return promise + }, + history: { + more(sessionID: string) { + if (store.message[sessionID] === undefined) return false + if (meta.limit[sessionID] === undefined) return false + if (meta.complete[sessionID]) return false + return true + }, + loading(sessionID: string) { + return meta.loading[sessionID] ?? false + }, + async loadMore(sessionID: string, count = chunk) { + if (meta.loading[sessionID]) return + if (meta.complete[sessionID]) return + + const current = meta.limit[sessionID] ?? chunk + await loadMessages(sessionID, current + count) + }, }, fetch: async (count = 10) => { setStore("limit", (x) => x + count) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 2ee0d137e38..43672fcb3ea 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -1,6 +1,6 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createMemo } from "solid-js" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import { Persist, persisted } from "@/utils/persist" @@ -14,108 +14,175 @@ export type LocalPTY = { scrollY?: number } -export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ - name: "Terminal", - init: () => { - const sdk = useSDK() - const params = useParams() - const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) - - const [store, setStore, _, ready] = persisted( - Persist.scoped(params.dir!, params.id, "terminal", [legacy()]), - createStore<{ - active?: string - all: LocalPTY[] - }>({ - all: [], - }), - ) +const WORKSPACE_KEY = "__workspace__" +const MAX_TERMINAL_SESSIONS = 20 - return { - ready, - all: createMemo(() => Object.values(store.all)), - active: createMemo(() => store.active), - new() { - sdk.client.pty - .create({ title: `Terminal ${store.all.length + 1}` }) - .then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("active", id) - }) - .catch((e) => { - console.error("Failed to create terminal", e) - }) - }, - update(pty: Partial & { id: string }) { - setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((e) => { - console.error("Failed to update terminal", e) - }) - }, - async clone(id: string) { - const index = store.all.findIndex((x) => x.id === id) - const pty = store.all[index] - if (!pty) return - const clone = await sdk.client.pty - .create({ - title: pty.title, - }) - .catch((e) => { - console.error("Failed to clone terminal", e) - return undefined - }) - if (!clone?.data) return - setStore("all", index, { - ...pty, - ...clone.data, +type TerminalSession = ReturnType + +type TerminalCacheEntry = { + value: TerminalSession + dispose: VoidFunction +} + +function createTerminalSession(sdk: ReturnType, dir: string, id: string | undefined) { + const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1` + + const [store, setStore, _, ready] = persisted( + Persist.scoped(dir, id, "terminal", [legacy]), + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + ) + + return { + ready, + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty + .create({ title: `Terminal ${store.all.length + 1}` }) + .then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) }) - if (store.active === pty.id) { - setStore("active", clone.data.id) - } - }, - open(id: string) { - setStore("active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "all", - store.all.filter((x) => x.id !== id), - ) - if (store.active === id) { - const index = store.all.findIndex((f) => f.id === id) - const previous = store.all[Math.max(0, index - 1)] - setStore("active", previous?.id) - } + .catch((e) => { + console.error("Failed to create terminal", e) + }) + }, + update(pty: Partial & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((e) => { + console.error("Failed to update terminal", e) }) - await sdk.client.pty.remove({ ptyID: id }).catch((e) => { - console.error("Failed to close terminal", e) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty + .create({ + title: pty.title, }) - }, - move(id: string, to: number) { - const index = store.all.findIndex((f) => f.id === id) - if (index === -1) return + .catch((e) => { + console.error("Failed to clone terminal", e) + return undefined + }) + if (!clone?.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { setStore( "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), + store.all.filter((x) => x.id !== id), ) - }, + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { + console.error("Failed to close terminal", e) + }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + gate: false, + init: () => { + const sdk = useSDK() + const params = useParams() + const cache = new Map() + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_TERMINAL_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const load = (dir: string, id: string | undefined) => { + const key = `${dir}:${id ?? WORKSPACE_KEY}` + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createTerminalSession(sdk, dir, id), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value + } + + const session = createMemo(() => load(params.dir!, params.id)) + + return { + ready: () => session().ready(), + all: () => session().all(), + active: () => session().active(), + new: () => session().new(), + update: (pty: Partial & { id: string }) => session().update(pty), + clone: (id: string) => session().clone(id), + open: (id: string) => session().open(id), + close: (id: string) => session().close(id), + move: (id: string, to: number) => session().move(id, to), } }, }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 85d61d57beb..7c31d2aa5e6 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1,4 +1,5 @@ import { + batch, createEffect, createMemo, createSignal, @@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" -import { createStore, produce } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, @@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -55,6 +57,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { useServer } from "@/context/server" @@ -284,6 +287,146 @@ export default function Layout(props: ParentProps) { const currentSessions = createMemo(() => projectSessions(currentProject())) + type PrefetchQueue = { + inflight: Set + pending: string[] + pendingSet: Set + running: number + } + + const prefetchChunk = 200 + const prefetchConcurrency = 1 + const prefetchPendingLimit = 6 + const prefetchToken = { value: 0 } + const prefetchQueues = new Map() + + createEffect(() => { + params.dir + globalSDK.url + + prefetchToken.value += 1 + for (const q of prefetchQueues.values()) { + q.pending.length = 0 + q.pendingSet.clear() + } + }) + + const queueFor = (directory: string) => { + const existing = prefetchQueues.get(directory) + if (existing) return existing + + const created: PrefetchQueue = { + inflight: new Set(), + pending: [], + pendingSet: new Set(), + running: 0, + } + prefetchQueues.set(directory, created) + return created + } + + const prefetchMessages = (directory: string, sessionID: string, token: number) => { + const [, setStore] = globalSync.child(directory) + + return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) + .then((messages) => { + if (prefetchToken.value !== token) return + + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const next = items + .map((x) => x.info) + .filter((m) => !!m?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + + batch(() => { + setStore("message", sessionID, reconcile(next, { key: "id" })) + + for (const message of items) { + setStore( + "part", + message.info.id, + reconcile( + message.parts + .filter((p) => !!p?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }) + .catch(() => undefined) + } + + const pumpPrefetch = (directory: string) => { + const q = queueFor(directory) + if (q.running >= prefetchConcurrency) return + + const sessionID = q.pending.shift() + if (!sessionID) return + + q.pendingSet.delete(sessionID) + q.inflight.add(sessionID) + q.running += 1 + + const token = prefetchToken.value + + void prefetchMessages(directory, sessionID, token).finally(() => { + q.running -= 1 + q.inflight.delete(sessionID) + pumpPrefetch(directory) + }) + } + + const prefetchSession = (session: Session, priority: "high" | "low" = "low") => { + const directory = session.directory + if (!directory) return + + const [store] = globalSync.child(directory) + if (store.message[session.id] !== undefined) return + + const q = queueFor(directory) + if (q.inflight.has(session.id)) return + if (q.pendingSet.has(session.id)) return + + if (priority === "high") q.pending.unshift(session.id) + if (priority !== "high") q.pending.push(session.id) + q.pendingSet.add(session.id) + + while (q.pending.length > prefetchPendingLimit) { + const dropped = q.pending.pop() + if (!dropped) continue + q.pendingSet.delete(dropped) + } + + pumpPrefetch(directory) + } + + createEffect(() => { + const sessions = currentSessions() + const id = params.id + + if (!id) { + const first = sessions[0] + if (first) prefetchSession(first) + + const second = sessions[1] + if (second) prefetchSession(second) + return + } + + const index = sessions.findIndex((s) => s.id === id) + if (index === -1) return + + const next = sessions[index + 1] + if (next) prefetchSession(next) + + const prev = sessions[index - 1] + if (prev) prefetchSession(prev) + }) + function navigateSessionByOffset(offset: number) { const projects = layout.projects.list() if (projects.length === 0) return @@ -309,6 +452,27 @@ export default function Layout(props: ParentProps) { if (targetIndex >= 0 && targetIndex < sessions.length) { const session = sessions[targetIndex] + const next = sessions[targetIndex + 1] + const prev = sessions[targetIndex - 1] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) + } + + if (import.meta.env.DEV) { + navStart({ + dir: base64Encode(session.directory), + from: params.id, + to: session.id, + trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", + }) + } navigateToSession(session) queueMicrotask(() => scrollToSession(session.id)) return @@ -324,7 +488,27 @@ export default function Layout(props: ParentProps) { return } - const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] + const index = offset > 0 ? 0 : nextProjectSessions.length - 1 + const targetSession = nextProjectSessions[index] + const nextSession = nextProjectSessions[index + 1] + const prevSession = nextProjectSessions[index - 1] + + if (offset > 0) { + if (nextSession) prefetchSession(nextSession, "high") + } + + if (offset < 0) { + if (prevSession) prefetchSession(prevSession, "high") + } + + if (import.meta.env.DEV) { + navStart({ + dir: base64Encode(targetSession.directory), + from: params.id, + to: targetSession.id, + trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", + }) + } navigateToSession(targetSession) queueMicrotask(() => scrollToSession(targetSession.id)) } @@ -679,6 +863,8 @@ export default function Layout(props: ParentProps) { prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} >
, +} + interface SessionReviewTabProps { diffs: () => FileDiff[] view: () => ReturnType["view"]> @@ -162,6 +170,46 @@ export default function Page() { const tabs = createMemo(() => layout.tabs(sessionKey())) const view = createMemo(() => layout.view(sessionKey())) + if (import.meta.env.DEV) { + createEffect( + on( + () => [params.dir, params.id] as const, + ([dir, id], prev) => { + if (!id) return + navParams({ dir, from: prev?.[1], to: id }) + }, + ), + ) + + createEffect(() => { + const id = params.id + if (!id) return + if (!prompt.ready()) return + navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" }) + }) + + createEffect(() => { + const id = params.id + if (!id) return + if (!terminal.ready()) return + navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" }) + }) + + createEffect(() => { + const id = params.id + if (!id) return + if (!file.ready()) return + navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" }) + }) + + createEffect(() => { + const id = params.id + if (!id) return + if (sync.data.message[id] === undefined) return + navMark({ dir: params.dir, to: id, name: "session:data-ready" }) + }) + } + const isDesktop = createMediaQuery("(min-width: 768px)") function normalizeTab(tab: string) { @@ -216,6 +264,8 @@ export default function Page() { }) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const reviewCount = createMemo(() => info()?.summary?.files ?? 0) + const hasReview = createMemo(() => reviewCount() > 0) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messagesReady = createMemo(() => { @@ -223,6 +273,16 @@ export default function Page() { if (!id) return true return sync.data.message[id] !== undefined }) + const historyMore = createMemo(() => { + const id = params.id + if (!id) return false + return sync.session.history.more(id) + }) + const historyLoading = createMemo(() => { + const id = params.id + if (!id) return false + return sync.session.history.loading(id) + }) const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages) const visibleUserMessages = createMemo(() => { @@ -249,11 +309,20 @@ export default function Page() { activeTerminalDraggable: undefined as string | undefined, expanded: {} as Record, messageId: undefined as string | undefined, + turnStart: 0, mobileTab: "session" as "session" | "review", newSessionWorktree: "main", promptHeight: 0, }) + const renderedUserMessages = createMemo(() => { + const msgs = visibleUserMessages() + const start = store.turnStart + if (start <= 0) return msgs + if (start >= msgs.length) return emptyUserMessages + return msgs.slice(start) + }, emptyUserMessages) + const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" const project = sync.project @@ -290,6 +359,12 @@ export default function Page() { } const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) const idle = { type: "idle" as const } let inputRef!: HTMLDivElement @@ -302,11 +377,10 @@ export default function Page() { }) createEffect(() => { - if (layout.terminal.opened()) { - if (terminal.all().length === 0) { - terminal.new() - } - } + if (!layout.terminal.opened()) return + if (!terminal.ready()) return + if (terminal.all().length !== 0) return + terminal.new() }) createEffect( @@ -643,12 +717,10 @@ export default function Page() { .filter((tab) => tab !== "context"), ) - const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review") - const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review") + const reviewTab = createMemo(() => hasReview() || tabs().active() === "review") + const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review") - const showTabs = createMemo( - () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()), - ) + const showTabs = createMemo(() => layout.review.opened() && (hasReview() || tabs().all().length > 0 || contextOpen())) const activeTab = createMemo(() => { const active = tabs().active() @@ -664,10 +736,22 @@ export default function Page() { createEffect(() => { if (!layout.ready()) return if (tabs().active()) return - if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return + if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return tabs().setActive(activeTab()) }) + createEffect(() => { + const id = params.id + if (!id) return + if (!hasReview()) return + + const wants = isDesktop() ? layout.review.opened() && activeTab() === "review" : store.mobileTab === "review" + if (!wants) return + if (diffsReady()) return + + sync.session.diff(id) + }) + const isWorking = createMemo(() => status().type !== "idle") const autoScroll = createAutoScroll({ working: isWorking, @@ -683,6 +767,88 @@ export default function Page() { autoScroll.scrollRef(el) } + const turnInit = 20 + const turnBatch = 20 + let turnHandle: number | undefined + let turnIdle = false + + function cancelTurnBackfill() { + const handle = turnHandle + if (handle === undefined) return + turnHandle = undefined + + if (turnIdle && window.cancelIdleCallback) { + window.cancelIdleCallback(handle) + return + } + + clearTimeout(handle) + } + + function scheduleTurnBackfill() { + if (turnHandle !== undefined) return + if (store.turnStart <= 0) return + + if (window.requestIdleCallback) { + turnIdle = true + turnHandle = window.requestIdleCallback(() => { + turnHandle = undefined + backfillTurns() + }) + return + } + + turnIdle = false + turnHandle = window.setTimeout(() => { + turnHandle = undefined + backfillTurns() + }, 0) + } + + function backfillTurns() { + const start = store.turnStart + if (start <= 0) return + + const next = start - turnBatch + const nextStart = next > 0 ? next : 0 + + const el = scroller + if (!el) { + setStore("turnStart", nextStart) + scheduleTurnBackfill() + return + } + + const beforeTop = el.scrollTop + const beforeHeight = el.scrollHeight + + setStore("turnStart", nextStart) + + requestAnimationFrame(() => { + const delta = el.scrollHeight - beforeHeight + if (delta) el.scrollTop = beforeTop + delta + }) + + scheduleTurnBackfill() + } + + createEffect( + on( + () => [params.id, messagesReady()] as const, + ([id, ready]) => { + cancelTurnBackfill() + setStore("turnStart", 0) + if (!id || !ready) return + + const len = visibleUserMessages().length + const start = len > turnInit ? len - turnInit : 0 + setStore("turnStart", start) + scheduleTurnBackfill() + }, + { defer: true }, + ), + ) + createResizeObserver( () => promptDock, ({ height }) => { @@ -710,6 +876,21 @@ export default function Page() { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { setActiveMessage(message) + const msgs = visibleUserMessages() + const index = msgs.findIndex((m) => m.id === message.id) + if (index !== -1 && index < store.turnStart) { + setStore("turnStart", index) + scheduleTurnBackfill() + + requestAnimationFrame(() => { + const el = document.getElementById(anchor(message.id)) + if (el) el.scrollIntoView({ behavior, block: "start" }) + }) + + updateHash(message.id) + return + } + const el = document.getElementById(anchor(message.id)) if (el) el.scrollIntoView({ behavior, block: "start" }) updateHash(message.id) @@ -755,12 +936,27 @@ export default function Page() { if (!sessionID || !ready) return requestAnimationFrame(() => { - const id = window.location.hash.slice(1) - const hashTarget = id ? document.getElementById(id) : undefined + const hash = window.location.hash.slice(1) + if (!hash) { + autoScroll.forceScrollToBottom() + return + } + + const hashTarget = document.getElementById(hash) if (hashTarget) { hashTarget.scrollIntoView({ behavior: "auto", block: "start" }) return } + + const match = hash.match(/^message-(.+)$/) + if (match) { + const msg = visibleUserMessages().find((m) => m.id === match[1]) + if (msg) { + scrollToMessage(msg, "auto") + return + } + } + autoScroll.forceScrollToBottom() }) }) @@ -769,7 +965,43 @@ export default function Page() { document.addEventListener("keydown", handleKeyDown) }) + const previewPrompt = () => + prompt + .current() + .map((part) => { + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + if (part.type === "image") return `[image:${part.filename}]` + return part.content + }) + .join("") + .trim() + + createEffect(() => { + if (!prompt.ready()) return + handoff.prompt = previewPrompt() + }) + + createEffect(() => { + if (!terminal.ready()) return + handoff.terminals = terminal.all().map((t) => t.title) + }) + + createEffect(() => { + if (!file.ready()) return + handoff.files = Object.fromEntries( + tabs() + .all() + .flatMap((tab) => { + const path = file.pathFromTab(tab) + if (!path) return [] + return [[path, file.selectedLines(path) ?? null] as const] + }), + ) + }) + onCleanup(() => { + cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) }) @@ -779,7 +1011,7 @@ export default function Page() {
{/* Mobile tab bar - only shown on mobile when there are diffs */} - 0}> + setStore("mobileTab", "review")} > - {diffs().length} Files Changed + {reviewCount()} Files Changed @@ -821,21 +1053,26 @@ export default function Page() { when={!mobileReview()} fallback={
- { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", - header: "px-4", - container: "px-4", - }} - /> + Loading changes...
} + > + { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + classes={{ + root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + header: "px-4", + container: "px-4", + }} + /> +
} > @@ -868,42 +1105,82 @@ export default function Page() { "mt-0": showTabs(), }} > - - {(message) => ( -
0}> +
+ +
+ + +
+ +
+
+ + {(message) => { + if (import.meta.env.DEV) { + onMount(() => { + const id = params.id + if (!id) return + navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) + }) + } + + return ( +
-
- )} + > + + setStore("expanded", message.id, (open: boolean | undefined) => !open) + } + classes={{ + root: "min-w-0 w-full relative", + content: + "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + container: + "px-4 md:px-6 " + + (!showTabs() + ? "md:max-w-200 md:mx-auto" + : visibleUserMessages().length > 1 + ? "md:pr-6 md:pl-18" + : ""), + }} + /> +
+ ) + }}
@@ -944,13 +1221,22 @@ export default function Page() { "md:max-w-200": !showTabs(), }} > - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - /> + + {handoff.prompt || "Loading prompt..."} + + } + > + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + /> + @@ -1034,31 +1320,40 @@ export default function Page() { -
- { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> -
+ +
+ Loading changes...
} + > + { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + /> +
+ +
-
- -
+ +
+ +
+
@@ -1107,7 +1402,8 @@ export default function Page() { const selectedLines = createMemo(() => { const p = path() if (!p) return null - return file.selectedLines(p) ?? null + if (file.ready()) return file.selectedLines(p) ?? null + return handoff.files[p] ?? null }) const selection = createMemo(() => { const range = selectedLines() @@ -1204,37 +1500,63 @@ export default function Page() { }} onScroll={handleScroll} > - - {(sel) => ( - - )} - - - -
- {path()} -
-
- -
+ + + {(sel) => ( + + )} + + + +
+ {path()} +
+
+ +
+ { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + }} + overflow="scroll" + class="select-text" + /> + +
+ {path()} +
+
+
+
+ - -
- {path()} -
-
-
-
- - { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text pb-40" - /> - - -
Loading...
-
- - {(err) =>
{err()}
} -
-
+ + +
Loading...
+
+ + {(err) =>
{err()}
} +
+ + ) }} @@ -1316,54 +1614,74 @@ export default function Page() { onResize={layout.terminal.resize} onCollapse={layout.terminal.close} /> - - - - - - t.id)}> - {(pty) => } - -
- - - + +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
Loading...
- - - {(pty) => ( - - terminal.clone(pty.id)} /> - - )} - - - - - {(draggedId) => { - const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) - return ( - - {(t) => ( -
- {t().title} -
- )} -
- ) - }} -
-
- +
Loading terminal...
+
+ } + > + + + + + + t.id)}> + {(pty) => } + +
+ + + +
+
+ + {(pty) => ( + + terminal.clone(pty.id)} /> + + )} + +
+ + + {(draggedId) => { + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) + return ( + + {(t) => ( +
+ {t().title} +
+ )} +
+ ) + }} +
+
+
+
diff --git a/packages/app/src/utils/perf.ts b/packages/app/src/utils/perf.ts new file mode 100644 index 00000000000..0ecbc33ffc1 --- /dev/null +++ b/packages/app/src/utils/perf.ts @@ -0,0 +1,135 @@ +type Nav = { + id: string + dir?: string + from?: string + to: string + trigger?: string + start: number + marks: Record + logged: boolean + timer?: ReturnType +} + +const dev = import.meta.env.DEV + +const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}` + +const now = () => performance.now() + +const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2) + +const navs = new Map() +const pending = new Map() +const active = new Map() + +const required = [ + "session:params", + "session:data-ready", + "session:first-turn-mounted", + "storage:prompt-ready", + "storage:terminal-ready", + "storage:file-view-ready", +] + +function flush(id: string, reason: "complete" | "timeout") { + if (!dev) return + const nav = navs.get(id) + if (!nav) return + if (nav.logged) return + + nav.logged = true + if (nav.timer) clearTimeout(nav.timer) + + const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params" + const base = nav.marks[baseName] ?? nav.start + + const ms = Object.fromEntries( + Object.entries(nav.marks) + .slice() + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, t]) => [name, Math.round((t - base) * 100) / 100]), + ) + + console.log( + "perf.session-nav " + + JSON.stringify({ + type: "perf.session-nav.v0", + id: nav.id, + dir: nav.dir, + from: nav.from, + to: nav.to, + trigger: nav.trigger, + base: baseName, + reason, + ms, + }), + ) + + navs.delete(id) +} + +function maybeFlush(id: string) { + if (!dev) return + const nav = navs.get(id) + if (!nav) return + if (nav.logged) return + if (!required.every((name) => nav.marks[name] !== undefined)) return + flush(id, "complete") +} + +function ensure(id: string, data: Omit) { + const existing = navs.get(id) + if (existing) return existing + + const nav: Nav = { + ...data, + marks: {}, + logged: false, + } + nav.timer = setTimeout(() => flush(id, "timeout"), 5000) + navs.set(id, nav) + return nav +} + +export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) { + if (!dev) return + + const id = uid() + const start = now() + const nav = ensure(id, { ...input, id, start }) + nav.marks["navigate:start"] = start + + pending.set(key(input.dir, input.to), id) + return id +} + +export function navParams(input: { dir?: string; from?: string; to: string }) { + if (!dev) return + + const k = key(input.dir, input.to) + const pendingId = pending.get(k) + if (pendingId) pending.delete(k) + const id = pendingId ?? uid() + + const start = now() + const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" }) + nav.marks["session:params"] = start + + active.set(k, id) + maybeFlush(id) + return id +} + +export function navMark(input: { dir?: string; to: string; name: string }) { + if (!dev) return + + const id = active.get(key(input.dir, input.to)) + if (!id) return + + const nav = navs.get(id) + if (!nav) return + if (nav.marks[input.name] !== undefined) return + + nav.marks[input.name] = now() + maybeFlush(id) +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 6e40b700a27..2b0b018744b 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,19 +1,53 @@ import { useMarked } from "../context/marked" +import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createResource, splitProps } from "solid-js" +type Entry = { + hash: string + html: string +} + +const max = 200 +const cache = new Map() + +function touch(key: string, value: Entry) { + cache.delete(key) + cache.set(key, value) + + if (cache.size <= max) return + + const first = cache.keys().next().value + if (!first) return + cache.delete(first) +} + export function Markdown( props: ComponentProps<"div"> & { text: string + cacheKey?: string class?: string classList?: Record }, ) { - const [local, others] = splitProps(props, ["text", "class", "classList"]) + const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const marked = useMarked() const [html] = createResource( () => local.text, async (markdown) => { - return marked.parse(markdown) + const hash = checksum(markdown) + const key = local.cacheKey ?? hash + + if (key && hash) { + const cached = cache.get(key) + if (cached && cached.hash === hash) { + touch(key, cached) + return cached.html + } + } + + const next = await marked.parse(markdown) + if (key && hash) touch(key, { hash, html: next }) + return next }, { initialValue: "" }, ) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 8102c2ce715..534ea8f50ab 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -566,7 +566,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return (
- +
) @@ -580,7 +580,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { return (
- +
) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 005b6e5a393..f69d414be58 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -350,15 +350,31 @@ export function SessionTurn( onUserInteracted: props.onUserInteracted, }) + const diffInit = 20 + const diffBatch = 20 + const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, stickyHeaderHeight: 0, retrySeconds: 0, + diffsOpen: [] as string[], + diffLimit: diffInit, status: rawStatus(), duration: duration(), }) + createEffect( + on( + () => message()?.id, + () => { + setStore("diffsOpen", []) + setStore("diffLimit", diffInit) + }, + { defer: true }, + ), + ) + createEffect(() => { const r = retry() if (!r) { @@ -542,10 +558,23 @@ export function SessionTurn(

Response

- +
- - + { + if (!Array.isArray(value)) return + setStore("diffsOpen", value) + }} + > + {(diff) => ( @@ -573,22 +602,41 @@ export function SessionTurn( - + + + )} + store.diffLimit}> + +
diff --git a/packages/ui/src/context/helper.tsx b/packages/ui/src/context/helper.tsx index 53f9879458d..86684c876e6 100644 --- a/packages/ui/src/context/helper.tsx +++ b/packages/ui/src/context/helper.tsx @@ -3,12 +3,19 @@ import { createContext, createMemo, Show, useContext, type ParentProps, type Acc export function createSimpleContext>(input: { name: string init: ((input: Props) => T) | (() => T) + gate?: boolean }) { const ctx = createContext() return { provider: (props: ParentProps) => { const init = input.init(props) + const gate = input.gate ?? true + + if (!gate) { + return {props.children} + } + // Access init.ready inside the memo to make it reactive for getter properties const isReady = createMemo(() => { // @ts-expect-error