diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0eb5b4e9e0..fb3d30471d 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -133,7 +133,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( - + { void window.api?.setTitlebar?.({ mode }) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 930832fb65..0a2b6f56df 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -17,6 +17,7 @@ import { type JSXElement, type ParentProps, } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" @@ -201,6 +202,7 @@ export default function FileTree(props: { modified?: readonly string[] kinds?: ReadonlyMap draggable?: boolean + synthetic?: boolean onFileClick?: (file: FileNode) => void _filter?: Filter @@ -208,10 +210,15 @@ export default function FileTree(props: { _deeps?: Map _kinds?: ReadonlyMap _chain?: readonly string[] + _open?: Record + _setOpen?: SetStoreFunction> }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true + const local = createStore>({}) + const open = props._open ?? local[0] + const setOpen = props._setOpen ?? local[1] const key = (p: string) => file @@ -258,6 +265,7 @@ export default function FileTree(props: { const deeps = createMemo(() => { if (props._deeps) return props._deeps + if (props.synthetic) return new Map() const out = new Map() @@ -304,6 +312,7 @@ export default function FileTree(props: { }) createEffect(() => { + if (props.synthetic) return const current = filter() const dirs = dirsToExpand({ level, @@ -317,6 +326,7 @@ export default function FileTree(props: { on( () => props.path, (path) => { + if (props.synthetic) return const dir = untrack(() => file.tree.state(path)) if (!shouldListRoot({ level, dir })) return void file.tree.list(path) @@ -388,7 +398,8 @@ export default function FileTree(props: {
{(node) => { - const expanded = () => file.tree.state(node.path)?.expanded ?? false + const expanded = () => + props.synthetic ? (open[node.path] ?? true) : (file.tree.state(node.path)?.expanded ?? false) const deep = () => deeps().get(node.path) ?? -1 const kind = () => visibleKind(node, kinds(), marks()) const active = () => !!kind() && !node.ignored @@ -402,7 +413,13 @@ export default function FileTree(props: { data-scope="filetree" forceMount={false} open={expanded()} - onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + onOpenChange={(open) => { + if (props.synthetic) { + setOpen(node.path, open) + return + } + open ? file.tree.expand(node.path) : file.tree.collapse(node.path) + }} > diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ee98e68cd5..4a5d88b018 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -27,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" @@ -1494,7 +1493,11 @@ export const PromptInput: Component = (props) => { size="normal" class="min-w-0 max-w-[320px] text-13-regular text-text-base group" style={control()} - onClick={() => dialog.show(() => )} + onClick={() => { + void import("@/components/dialog-select-model-unpaid").then((x) => + dialog.show(() => ), + ) + }} > - retry(() => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - ), () => Promise.resolve(input.loadSessions(input.directory)), () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d..63cba5a655 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -56,6 +56,22 @@ function cleanupSessionCaches( ) } +function keep(next: Session, prev?: Session) { + const diffs = prev?.summary?.diffs + const files = prev?.summary?.files + if (!diffs?.length) return next + if (!next.summary || next.summary.diffs?.length) return next + if (next.summary.files <= 0) return next + if (next.summary.files !== files) return next + return { + ...next, + summary: { + ...next.summary, + diffs, + }, + } +} + export function cleanupDroppedSessionCaches( store: Store, setStore: SetStoreFunction, @@ -105,7 +121,7 @@ export function applyDirectoryEvent(input: { const info = (event.properties as { info: Session }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (result.found) { - input.setStore("session", result.index, reconcile(info)) + input.setStore("session", result.index, reconcile(keep(info, input.store.session[result.index]))) break } const next = input.store.session.slice() @@ -134,7 +150,7 @@ export function applyDirectoryEvent(input: { break } if (result.found) { - input.setStore("session", result.index, reconcile(info)) + input.setStore("session", result.index, reconcile(keep(info, input.store.session[result.index]))) break } const next = input.store.session.slice() diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index eddd752eb4..60997676bd 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" @@ -120,7 +120,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont if (typeof document === "undefined") return const id = store.appearance?.font ?? defaultSettings.appearance.font if (id !== defaultSettings.appearance.font) { - void loadFont().then((x) => x.ensureMonoFont(id)) + const run = () => { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + if (typeof requestIdleCallback === "function") { + const idle = requestIdleCallback(run, { timeout: 2000 }) + onCleanup(() => cancelIdleCallback(idle)) + } else { + const timeout = window.setTimeout(run, 2000) + onCleanup(() => window.clearTimeout(timeout)) + } } document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index bbf4fc5ec4..a121607824 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,8 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const initialMessagePageSize = 80 - const historyMessagePageSize = 200 + const initialMessagePageSize = 40 + const historyMessagePageSize = 80 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -460,13 +460,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } - const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const hit = Binary.search(store.session, sessionID, (s) => s.id) + const session = hit.found ? store.session[hit.index] : undefined + const hasSession = hit.found const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined - if (cached && hasSession && !opts?.force) return + const needs = !!session?.summary?.files && !session.summary?.diffs + if (cached && hasSession && !opts?.force && !needs) return const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = - hasSession && !opts?.force + hasSession && !opts?.force && !needs ? Promise.resolve() : retry(() => client.session.get({ sessionID })).then((session) => { if (!tracked(directory, sessionID)) return diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a8f2360bbf..e07ab78304 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -21,7 +21,7 @@ export function useProviders() { const dir = createMemo(() => decode64(params.dir) ?? "") const providers = () => { if (dir()) { - const [projectStore] = globalSync.child(dir()) + const [projectStore] = globalSync.peek(dir(), { bootstrap: false }) if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2d3e31355a..b86e5d15b6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -158,6 +158,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { preserveScroll(() => setTurnStart(nextStart)) } + const reveal = () => { + const start = turnStart() + if (start <= 0) return false + backfillTurns() + return true + } + /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ const loadAndReveal = async () => { const id = input.sessionID() @@ -303,6 +310,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { return { turnStart, setTurnStart, + reveal, renderedUserMessages, loadAndReveal, onScrollerScroll, @@ -877,6 +885,7 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsDiff = createMemo(() => (isDesktop() ? desktopReviewOpen() && activeTab() === "review" : mobileChanges())) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -1074,6 +1083,7 @@ export default function Page() { } const focusReviewDiff = (path: string) => { + void tabs().open("review") openReviewPanel() view().review.openPath(path) setTree({ activeDiff: path, pendingDiff: path }) @@ -1124,10 +1134,7 @@ export default function Page() { const id = params.id if (!id) return - const wants = isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes" - if (!wants) return + if (!wantsDiff()) return if (sync.data.session_diff[id] !== undefined) return if (sync.status === "loading") return @@ -1136,13 +1143,7 @@ export default function Page() { createEffect( on( - () => - [ - sessionKey(), - isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes", - ] as const, + () => [sessionKey(), wantsDiff()] as const, ([key, wants]) => { if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffTimer !== undefined) window.clearTimeout(diffTimer) @@ -1167,19 +1168,6 @@ export default function Page() { ), ) - let treeDir: string | undefined - createEffect(() => { - const dir = sdk.directory - if (!isDesktop()) return - if (!layout.fileTree.opened()) return - if (sync.status === "loading") return - - fileTreeTab() - const refresh = treeDir !== dir - treeDir = dir - void (refresh ? file.tree.refresh("") : file.tree.list("")) - }) - createEffect( on( () => sdk.directory, @@ -1296,9 +1284,9 @@ export default function Page() { const el = scroller if (!el) return if (el.scrollHeight > el.clientHeight + 1) return - if (historyWindow.turnStart() <= 0 && !historyMore()) return + if (historyWindow.turnStart() <= 0) return - void historyWindow.loadAndReveal() + historyWindow.reveal() }) } @@ -1309,14 +1297,13 @@ export default function Page() { params.id, messagesReady(), historyWindow.turnStart(), - historyMore(), historyLoading(), autoScroll.userScrolled(), visibleUserMessages().length, ] as const, - ([id, ready, start, more, loading, scrolled]) => { + ([id, ready, start, loading, scrolled]) => { if (!id || !ready || loading || scrolled) return - if (start <= 0 && !more) return + if (start <= 0) return fill() }, { defer: true }, diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b..701b1fa0f1 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -49,7 +49,6 @@ export const createSessionTabs = (input: TabsInput) => { const first = openedTabs()[0] if (first) return first if (contextOpen()) return "context" - if (review() && hasReview()) return "review" return "empty" }) const activeFileTab = createMemo(() => { diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 58c650fcd1..4e38b99f97 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -13,7 +13,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" import { SessionContextUsage } from "@/components/session-context-usage" -import { DialogSelectFile } from "@/components/dialog-select-file" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" @@ -56,14 +55,15 @@ export function SessionSidePanel(props: { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const changes = createMemo(() => { + const id = params.id + if (!id) return [] + const full = sync.data.session_diff[id] + if (full !== undefined) return full + return info()?.summary?.diffs ?? [] + }) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasReview = createMemo(() => reviewCount() > 0) - const diffsReady = createMemo(() => { - const id = params.id - if (!id) return true - if (!hasReview()) return true - return sync.data.session_diff[id] !== undefined - }) const reviewEmptyKey = createMemo(() => { if (sync.project && !sync.project.vcs) return "session.review.noVcs" @@ -71,7 +71,7 @@ export function SessionSidePanel(props: { return "session.review.noChanges" }) - const diffFiles = createMemo(() => diffs().map((d) => d.file)) + const diffFiles = createMemo(() => changes().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -82,7 +82,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of diffs()) { + for (const diff of changes()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" @@ -293,9 +293,11 @@ export function SessionSidePanel(props: { variant="ghost" iconSize="large" class="!rounded-md" - onClick={() => - dialog.show(() => ) - } + onClick={() => { + void import("@/components/dialog-select-file").then((x) => + dialog.show(() => ), + ) + }} aria-label={language.t("command.file.open")} /> @@ -386,26 +388,17 @@ export function SessionSidePanel(props: { - - - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
- } - > - props.focusReviewDiff(node.path)} - /> - + 0}> + props.focusReviewDiff(node.path)} + /> {empty( diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 7394765ae6..de6660f69a 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -11,10 +11,6 @@ import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { DialogSelectFile } from "@/components/dialog-select-file" -import { DialogSelectModel } from "@/components/dialog-select-model" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" -import { DialogFork } from "@/components/dialog-fork" import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/util/array" import { createSessionTabs } from "@/pages/session/helpers" @@ -257,7 +253,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { description: language.t("palette.search.placeholder"), keybind: "mod+k,mod+p", slash: "open", - onSelect: () => dialog.show(() => ), + onSelect: () => { + void import("@/components/dialog-select-file").then((x) => + dialog.show(() => ), + ) + }, }), fileCommand({ id: "tab.close", @@ -351,7 +351,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { description: language.t("command.model.choose.description"), keybind: "mod+'", slash: "model", - onSelect: () => dialog.show(() => ), + onSelect: () => { + void import("@/components/dialog-select-model").then((x) => + dialog.show(() => ), + ) + }, }), mcpCommand({ id: "mcp.toggle", @@ -359,7 +363,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { description: language.t("command.mcp.toggle.description"), keybind: "mod+;", slash: "mcp", - onSelect: () => dialog.show(() => ), + onSelect: () => { + void import("@/components/dialog-select-mcp").then((x) => dialog.show(() => )) + }, }), agentCommand({ id: "agent.cycle", @@ -487,7 +493,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { description: language.t("command.session.fork.description"), slash: "fork", disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => ), + onSelect: () => { + void import("@/components/dialog-fork").then((x) => dialog.show(() => )) + }, }), ...share, ] diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 3c9ebfdc5e..73c470876c 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -123,6 +123,15 @@ export const SessionRoutes = lazy(() => const sessionID = c.req.valid("param").sessionID log.info("SEARCH", { url: c.req.url }) const session = await Session.get(sessionID) + if (session.summary?.files) { + const diffs = await SessionSummary.list(sessionID) + if (diffs.length > 0) { + session.summary = { + ...session.summary, + diffs, + } + } + } return c.json(session) }, ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e4c98c609e..a8f5f59d08 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -51,6 +51,58 @@ globalThis.AI_SDK_LOG_WARNINGS = false const csp = (hash = "") => `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` +const api = (path: string) => + path === "/agent" || + path === "/command" || + path === "/formatter" || + path === "/log" || + path === "/lsp" || + path === "/path" || + path === "/skill" || + path === "/vcs" || + path.startsWith("/auth/") || + path.startsWith("/config") || + path.startsWith("/experimental") || + path.startsWith("/global") || + path.startsWith("/mcp") || + path.startsWith("/permission") || + path.startsWith("/project") || + path.startsWith("/provider") || + path.startsWith("/question") || + path.startsWith("/session") + +const json = (value: string | null) => { + const type = value?.split(";")[0]?.trim() + return type === "application/json" || type?.endsWith("+json") +} + +const gzip = (value?: string) => { + if (!value) return false + + let star = false + for (const item of value.split(",")) { + const [name, ...params] = item.trim().toLowerCase().split(";") + const q = params.find((part) => part.trim().startsWith("q=")) + const score = q ? Number(q.trim().slice(2)) : 1 + const ok = !Number.isNaN(score) && score > 0 + + if (name === "gzip") return ok + if (name === "*") star = ok + } + + return star +} + +const vary = (headers: Headers, value: string) => { + const current = headers.get("Vary") + if (!current) { + headers.set("Vary", value) + return + } + if (current.split(",").some((item) => item.trim().toLowerCase() === value.toLowerCase())) return + headers.set("Vary", `${current}, ${value}`) +} + export namespace Server { const log = Log.create({ service: "server" }) @@ -104,6 +156,26 @@ export namespace Server { timer.stop() } }) + .use(async (c, next) => { + await next() + + if (!api(c.req.path)) return + if (c.req.method === "HEAD") return + if (c.res.headers.has("Content-Encoding")) return + if (c.res.headers.has("Transfer-Encoding")) return + if (!json(c.res.headers.get("Content-Type"))) return + + if (!gzip(c.req.header("Accept-Encoding"))) return + + const size = Number(c.res.headers.get("Content-Length") ?? "") + if (Number.isFinite(size) && size > 0 && size < 1024) return + if (!c.res.body) return + + c.res = new Response(c.res.body.pipeThrough(new CompressionStream("gzip")), c.res) + c.res.headers.delete("Content-Length") + c.res.headers.set("Content-Encoding", "gzip") + vary(c.res.headers, "Accept-Encoding") + }) .use( cors({ origin(input) { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 898b93f3f9..07955152c5 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -12,6 +12,14 @@ import { Bus } from "@/bus" import { NotFoundError } from "@/storage/db" export namespace SessionSummary { + function shape(diffs: Snapshot.FileDiff[]) { + return diffs.map((item) => ({ + ...item, + before: "", + after: "", + })) + } + function unquoteGitPath(input: string) { if (!input.startsWith('"')) return input if (!input.endsWith('"')) return input @@ -141,6 +149,8 @@ export namespace SessionSummary { }, ) + export const list = fn(SessionID.zod, async (sessionID) => shape(await diff({ sessionID }))) + export async function computeDiff(input: { messages: MessageV2.WithParts[] }) { let from: string | undefined let to: string | undefined diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index e0d20152b9..46e69d085d 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -5,6 +5,31 @@ import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" export { type Config as OpencodeClientConfig, OpencodeClient } +const keys = [ + ["x-opencode-directory", "directory"], + ["x-opencode-workspace", "workspace"], +] as const + +function move(req: Request) { + if (req.method !== "GET" && req.method !== "HEAD") return req + + let url: URL | undefined + + for (const [header, key] of keys) { + const value = req.headers.get(header) + if (!value) continue + url ??= new URL(req.url) + if (!url.searchParams.has(key)) url.searchParams.set(key, value) + } + + if (!url) return req + const next = new Request(url, req) + for (const [header] of keys) { + next.headers.delete(header) + } + return next +} + export function createOpencodeClient(config?: Config & { directory?: string }) { if (!config?.fetch) { const customFetch: any = (req: any) => { @@ -26,5 +51,8 @@ export function createOpencodeClient(config?: Config & { directory?: string }) { } const client = createClient(config) + if (typeof window === "object" && typeof document === "object") { + client.interceptors.request.use(move) + } return new OpencodeClient({ client }) } diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index ad956dd4b3..3ebf58b855 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -5,6 +5,31 @@ import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" export { type Config as OpencodeClientConfig, OpencodeClient } +const keys = [ + ["x-opencode-directory", "directory"], + ["x-opencode-workspace", "workspace"], +] as const + +function move(req: Request) { + if (req.method !== "GET" && req.method !== "HEAD") return req + + let url: URL | undefined + + for (const [header, key] of keys) { + const value = req.headers.get(header) + if (!value) continue + url ??= new URL(req.url) + if (!url.searchParams.has(key)) url.searchParams.set(key, value) + } + + if (!url) return req + const next = new Request(url, req) + for (const [header] of keys) { + next.headers.delete(header) + } + return next +} + export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) { if (!config?.fetch) { const customFetch: any = (req: any) => { @@ -35,5 +60,8 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp } const client = createClient(config) + if (typeof window === "object" && typeof document === "object") { + client.interceptors.request.use(move) + } return new OpencodeClient({ client }) } diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index e1a508f16a..d4af124ea9 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -5,7 +5,7 @@ import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" -export const Font = () => { +export const Font = (props: { preloadMono?: boolean }) => { return ( <> - + + + )