diff options
| author | Shoubhit Dash <[email protected]> | 2026-03-06 18:19:15 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-06 06:49:15 -0600 |
| commit | 1d9dcd2a27736b83d20abc0111141fdd6bffde7e (patch) | |
| tree | 70d40994ed50d7f068c1415a67d9d8782d78d9af | |
| parent | eeeb21ff8638eddd960afbd8f522c87d850d6183 (diff) | |
| download | opencode-1d9dcd2a27736b83d20abc0111141fdd6bffde7e.tar.gz opencode-1d9dcd2a27736b83d20abc0111141fdd6bffde7e.zip | |
share: speed up share loads (#16165)
| -rw-r--r-- | packages/enterprise/src/core/share.ts | 140 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/api/[...path].ts | 1 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/share/[shareID].tsx | 90 | ||||
| -rw-r--r-- | packages/enterprise/test/core/share.test.ts | 30 | ||||
| -rw-r--r-- | packages/opencode/src/share/share-next.ts | 32 | ||||
| -rw-r--r-- | packages/ui/src/components/markdown.tsx | 17 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 2 |
7 files changed, 167 insertions, 145 deletions
diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index d7f5c8b8d..c6291b75d 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,10 +1,8 @@ import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" import { fn } from "@opencode-ai/util/fn" import { iife } from "@opencode-ai/util/iife" -import { Identifier } from "@opencode-ai/util/identifier" import z from "zod" import { Storage } from "./storage" -import { Binary } from "@opencode-ai/util/binary" export namespace Share { export const Info = z.object({ @@ -38,6 +36,81 @@ export namespace Share { ]) export type Data = z.infer<typeof Data> + type Snapshot = { + data: Data[] + } + + type Compaction = { + event?: string + data: Data[] + } + + function key(item: Data) { + switch (item.type) { + case "session": + return "session" + case "message": + return `message/${item.data.id}` + case "part": + return `part/${item.data.messageID}/${item.data.id}` + case "session_diff": + return "session_diff" + case "model": + return "model" + } + } + + function merge(...items: Data[][]) { + const map = new Map<string, Data>() + for (const list of items) { + for (const item of list) { + map.set(key(item), item) + } + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, item]) => item) + } + + async function readSnapshot(shareID: string) { + return (await Storage.read<Snapshot>(["share_snapshot", shareID]))?.data + } + + async function writeSnapshot(shareID: string, data: Data[]) { + await Storage.write<Snapshot>(["share_snapshot", shareID], { data }) + } + + async function legacy(shareID: string) { + const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? { + data: [], + event: undefined, + } + const list = await Storage.list({ + prefix: ["share_event", shareID], + before: compaction.event, + }).then((x) => x.toReversed()) + if (list.length === 0) { + if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data) + return compaction.data + } + + const next = merge( + compaction.data, + await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => + x.flatMap((item) => item ?? []), + ), + ) + + await Promise.all([ + Storage.write(["share_compaction", shareID], { + event: list.at(-1)?.at(-1), + data: next, + }), + writeSnapshot(shareID, next), + ]) + return next + } + export const create = fn(z.object({ sessionID: z.string() }), async (body) => { const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_") const info: Info = { @@ -47,7 +120,7 @@ export namespace Share { } const exists = await get(info.id) if (exists) throw new Errors.AlreadyExists(info.id) - await Storage.write(["share", info.id], info) + await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])]) return info }) @@ -60,8 +133,13 @@ export namespace Share { if (!share) throw new Errors.NotFound(body.id) if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id) await Storage.remove(["share", body.id]) - const list = await Storage.list({ prefix: ["share_data", body.id] }) - for (const item of list) { + const groups = await Promise.all([ + Storage.list({ prefix: ["share_snapshot", body.id] }), + Storage.list({ prefix: ["share_compaction", body.id] }), + Storage.list({ prefix: ["share_event", body.id] }), + Storage.list({ prefix: ["share_data", body.id] }), + ]) + for (const item of groups.flat()) { await Storage.remove(item) } }) @@ -75,59 +153,13 @@ export namespace Share { const share = await get(input.share.id) if (!share) throw new Errors.NotFound(input.share.id) if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id) - await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data) + const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id)) + await writeSnapshot(input.share.id, merge(data, input.data)) }, ) - type Compaction = { - event?: string - data: Data[] - } - export async function data(shareID: string) { - console.log("reading compaction") - const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? { - data: [], - event: undefined, - } - console.log("reading pending events") - const list = await Storage.list({ - prefix: ["share_event", shareID], - before: compaction.event, - }).then((x) => x.toReversed()) - - console.log("compacting", list.length) - - if (list.length > 0) { - const data = await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => x.flat()) - for (const item of data) { - if (!item) continue - const key = (item: Data) => { - switch (item.type) { - case "session": - return "session" - case "message": - return `message/${item.data.id}` - case "part": - return `${item.data.messageID}/${item.data.id}` - case "session_diff": - return "session_diff" - case "model": - return "model" - } - } - const id = key(item) - const result = Binary.search(compaction.data, id, key) - if (result.found) { - compaction.data[result.index] = item - } else { - compaction.data.splice(result.index, 0, item) - } - } - compaction.event = list.at(-1)?.at(-1) - await Storage.write(["share_compaction", shareID], compaction) - } - return compaction.data + return (await readSnapshot(shareID)) ?? legacy(shareID) } export const syncOld = fn( diff --git a/packages/enterprise/src/routes/api/[...path].ts b/packages/enterprise/src/routes/api/[...path].ts index e77c00de9..f97788bd0 100644 --- a/packages/enterprise/src/routes/api/[...path].ts +++ b/packages/enterprise/src/routes/api/[...path].ts @@ -108,6 +108,7 @@ app validator("param", z.object({ shareID: z.string() })), async (c) => { const { shareID } = c.req.valid("param") + c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400") return c.json(await Share.data(shareID)) }, ) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 007b4c268..e755ea75a 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -5,12 +5,11 @@ import { DataProvider } from "@opencode-ai/ui/context" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool" import { createAsync, query, useParams } from "@solidjs/router" -import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" +import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js" import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { createDefaultOptions } from "@opencode-ai/ui/pierre" import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { NamedError } from "@opencode-ai/util/error" @@ -20,11 +19,11 @@ import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" -import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { FileSSR } from "@opencode-ai/ui/file-ssr" import { clientOnly } from "@solidjs/start" import { Meta, Title } from "@solidjs/meta" import { Base64 } from "js-base64" +import { getRequestEvent } from "solid-js/web" const ClientOnlyWorkerPoolProvider = clientOnly(() => import("@opencode-ai/ui/pierre/worker").then((m) => ({ @@ -54,12 +53,6 @@ const getData = query(async (shareID) => { session_diff: { [sessionID: string]: FileDiff[] } - session_diff_preload: { - [sessionID: string]: PreloadMultiFileDiffResult<any>[] - } - session_diff_preload_split: { - [sessionID: string]: PreloadMultiFileDiffResult<any>[] - } session_status: { [sessionID: string]: SessionStatus } @@ -79,12 +72,6 @@ const getData = query(async (shareID) => { session_diff: { [share.sessionID]: [], }, - session_diff_preload: { - [share.sessionID]: [], - }, - session_diff_preload_split: { - [share.sessionID]: [], - }, session_status: { [share.sessionID]: { type: "idle", @@ -101,28 +88,6 @@ const getData = query(async (shareID) => { break case "session_diff": result.session_diff[share.sessionID] = item.data - await Promise.all([ - Promise.all( - item.data.map(async (diff) => - preloadMultiFileDiff<any>({ - oldFile: { name: diff.file, contents: diff.before }, - newFile: { name: diff.file, contents: diff.after }, - options: createDefaultOptions("unified"), - // annotations, - }), - ), - ).then((r) => (result.session_diff_preload[share.sessionID] = r)), - Promise.all( - item.data.map(async (diff) => - preloadMultiFileDiff<any>({ - oldFile: { name: diff.file, contents: diff.before }, - newFile: { name: diff.file, contents: diff.after }, - options: createDefaultOptions("split"), - // annotations, - }), - ), - ).then((r) => (result.session_diff_preload_split[share.sessionID] = r)), - ]) break case "message": result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? [] @@ -143,17 +108,15 @@ const getData = query(async (shareID) => { }, "getShareData") export default function () { + getRequestEvent()?.response.headers.set( + "Cache-Control", + "public, max-age=30, s-maxage=300, stale-while-revalidate=86400", + ) + const params = useParams() const data = createAsync(async () => { if (!params.shareID) throw new Error("Missing shareID") - const now = Date.now() - const data = getData(params.shareID) - console.log("getData", Date.now() - now) - return data - }) - - createEffect(() => { - console.log(data()) + return getData(params.shareID) }) return ( @@ -241,22 +204,8 @@ export default function () { const provider = createMemo(() => activeMessage()?.model?.providerID) const modelID = createMemo(() => activeMessage()?.model?.modelID) const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) - const splitDiffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) + const diffs = createMemo(() => data().session_diff[data().sessionID] ?? []) + const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified") const title = () => ( <div class="flex flex-col gap-4"> @@ -380,18 +329,9 @@ export default function () { <Show when={diffs().length > 0}> <div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base"> <SessionReview - class="@4xl:hidden" diffs={diffs()} - classes={{ - root: "pb-20", - header: "px-6", - container: "px-6", - }} - /> - <SessionReview - split - class="hidden @4xl:flex" - diffs={splitDiffs()} + diffStyle={diffStyle()} + onDiffStyleChange={setDiffStyle} classes={{ root: "pb-20", header: "px-6", @@ -419,11 +359,7 @@ export default function () { <Tabs.Content value="session" class="!overflow-hidden"> {turns()} </Tabs.Content> - <Tabs.Content - forceMount - value="review" - class="!overflow-hidden hidden data-[selected]:block" - > + <Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block"> <div class="relative h-full pt-8 overflow-y-auto no-scrollbar"> <SessionReview diffs={diffs()} diff --git a/packages/enterprise/test/core/share.test.ts b/packages/enterprise/test/core/share.test.ts index 9e9c06db3..d49d4b763 100644 --- a/packages/enterprise/test/core/share.test.ts +++ b/packages/enterprise/test/core/share.test.ts @@ -30,8 +30,8 @@ describe.concurrent("core.share", () => { data, }) - const events = await Storage.list({ prefix: ["share_event", share.id] }) - expect(events.length).toBe(1) + const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id]) + expect(snapshot?.data).toHaveLength(1) await Share.remove({ id: share.id, secret: share.secret }) }) @@ -64,8 +64,8 @@ describe.concurrent("core.share", () => { data: data2, }) - const events = await Storage.list({ prefix: ["share_event", share.id] }) - expect(events.length).toBe(2) + const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id]) + expect(snapshot?.data).toHaveLength(2) await Share.remove({ id: share.id, secret: share.secret }) }) @@ -194,6 +194,28 @@ describe.concurrent("core.share", () => { await Share.remove({ id: share.id, secret: share.secret }) }) + test("should migrate legacy event data into the snapshot", async () => { + const sessionID = Identifier.descending() + const share = await Share.create({ sessionID }) + const data: Share.Data[] = [ + { + type: "part", + data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" }, + }, + ] + + await Storage.remove(["share_snapshot", share.id]) + await Storage.write(["share_event", share.id, Identifier.descending()], data) + + const result = await Share.data(share.id) + const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id]) + + expect(result).toHaveLength(1) + expect(snapshot?.data).toHaveLength(1) + + await Share.remove({ id: share.id, secret: share.secret }) + }) + test("should throw error for invalid secret", async () => { const sessionID = Identifier.descending() const share = await Share.create({ sessionID }) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index c36616b7e..544376278 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,6 +1,5 @@ import { Bus } from "@/bus" import { Config } from "@/config/config" -import { ulid } from "ulid" import { Provider } from "@/provider/provider" import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" @@ -122,20 +121,35 @@ export namespace ShareNext { data: SDK.Model[] } + function key(item: Data) { + switch (item.type) { + case "session": + return "session" + case "message": + return `message/${item.data.id}` + case "part": + return `part/${item.data.messageID}/${item.data.id}` + case "session_diff": + return "session_diff" + case "model": + return "model" + } + } + const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>() async function sync(sessionID: string, data: Data[]) { if (disabled) return const existing = queue.get(sessionID) if (existing) { for (const item of data) { - existing.data.set("id" in item ? (item.id as string) : ulid(), item) + existing.data.set(key(item), item) } return } const dataMap = new Map<string, Data>() for (const item of data) { - dataMap.set("id" in item ? (item.id as string) : ulid(), item) + dataMap.set(key(item), item) } const timeout = setTimeout(async () => { @@ -182,10 +196,14 @@ export namespace ShareNext { const diffs = await Session.diff(sessionID) const messages = await Array.fromAsync(MessageV2.stream(sessionID)) const models = await Promise.all( - messages - .filter((m) => m.info.role === "user") - .map((m) => (m.info as SDK.UserMessage).model) - .map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)), + Array.from( + new Map( + messages + .filter((m) => m.info.role === "user") + .map((m) => (m.info as SDK.UserMessage).model) + .map((m) => [`${m.providerID}/${m.modelID}`, m] as const), + ).values(), + ).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)), ) await sync(sessionID, [ { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index bb41c74ef..01254f118 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -44,6 +44,19 @@ function sanitize(html: string) { return DOMPurify.sanitize(html, config) } +function escape(text: string) { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") +} + +function fallback(markdown: string) { + return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>") +} + type CopyLabels = { copy: string copied: string @@ -237,7 +250,7 @@ export function Markdown( const [html] = createResource( () => local.text, async (markdown) => { - if (isServer) return "" + if (isServer) return fallback(markdown) const hash = checksum(markdown) const key = local.cacheKey ?? hash @@ -255,7 +268,7 @@ export function Markdown( if (key && hash) touch(key, { hash, html: safe }) return safe }, - { initialValue: "" }, + { initialValue: isServer ? fallback(local.text) : "" }, ) let copySetupTimer: ReturnType<typeof setTimeout> | undefined diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index ad9e5b2c3..25a646ace 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => { const searchHandles = new Map<string, FileSearchHandle>() const readyFiles = new Set<string>() const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({ - open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), + open: [], force: {}, }) |
