diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 000000000000..8a01b58c0ca3 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,14 @@ +version: "3" + +tasks: + app: + desc: Build the web UI + dir: packages/app + cmds: + - bun run build + + build: + desc: Compile single binary for current platform + deps: [app] + cmds: + - bun ./packages/opencode/script/build.ts --single diff --git a/bun.lock b/bun.lock index 9cda088153c1..5d8118a87c32 100644 --- a/bun.lock +++ b/bun.lock @@ -509,7 +509,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.5", + "@types/bun": "1.3.6", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@typescript/native-preview": "7.0.0-dev.20251207.1", @@ -1777,7 +1777,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2079,7 +2079,7 @@ "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/package.json b/package.json index f1d6c4fead10..9aa069d52cc3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.6", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", @@ -21,7 +21,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.5", + "@types/bun": "1.3.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index aad0b596cb95..e6f8d130969e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" @@ -33,7 +33,7 @@ import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogFork } from "@/components/dialog-fork" import { useCommand } from "@/context/command" -import { useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { useSDK } from "@/context/sdk" @@ -167,6 +167,7 @@ export default function Page() { const sdk = useSDK() const prompt = usePrompt() const permission = usePermission() + const [searchParams] = useSearchParams() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const view = createMemo(() => layout.view(sessionKey())) @@ -305,6 +306,57 @@ export default function Page() { ), ) + createEffect( + on( + () => ({ + agents: local.agent.list(), + models: local.model.list(), + sessionId: params.id, + }), + (current, prev) => { + if (current.sessionId) return + + const wasNotReady = !prev || prev.agents.length === 0 || prev.models.length === 0 + const isNowReady = current.agents.length > 0 && current.models.length > 0 + + if (!wasNotReady || !isNowReady) return + + batch(() => { + const agentParam = Array.isArray(searchParams.agent) ? searchParams.agent[0] : searchParams.agent + if (agentParam) { + const agentName = agentParam.toLowerCase() + if (current.agents.some((a) => a.name === agentName)) { + local.agent.set(agentName) + } + } + + const modelParam = Array.isArray(searchParams.model) ? searchParams.model[0] : searchParams.model + if (modelParam) { + const [providerID, ...rest] = modelParam.toLowerCase().split("/") + const modelID = rest.join("/") + if (providerID && modelID) { + if (current.models.some((m) => m.provider.id === providerID && m.id === modelID)) { + local.model.set({ providerID, modelID }, { recent: true }) + } + } + } + + const variantParam = Array.isArray(searchParams.variant) ? searchParams.variant[0] : searchParams.variant + if (variantParam) { + const variants = local.model.variant.list() + const variantName = variantParam.toLowerCase() + if (variants.some((v) => v.toLowerCase() === variantName)) { + const exactVariant = variants.find((v) => v.toLowerCase() === variantName) + if (exactVariant) { + local.model.variant.set(exactVariant) + } + } + } + }) + }, + ), + ) + const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4cdb549096a0..78c97ca46b42 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -22,6 +22,7 @@ export namespace Flag { export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] + export const OPENCODE_APP_DIR = process.env["OPENCODE_APP_DIR"] // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 28dec7f4043b..83680a6c1f26 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,3 +1,4 @@ +import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Log } from "../util/log" @@ -503,8 +504,42 @@ export namespace Server { }, ) .all("/*", async (c) => { - const path = c.req.path - const response = await proxy(`https://app.opencode.ai${path}`, { + const reqPath = c.req.path + + // Serve from local directory if OPENCODE_APP_DIR is set + if (Flag.OPENCODE_APP_DIR) { + const filePath = reqPath === "/" ? "/index.html" : reqPath + const fullPath = path.join(Flag.OPENCODE_APP_DIR, filePath) + const file = Bun.file(fullPath) + + if (await file.exists()) { + return new Response(file, { + headers: { + "Content-Type": file.type, + "Content-Security-Policy": + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'", + }, + }) + } + + // SPA fallback: serve index.html for non-asset routes + const indexPath = path.join(Flag.OPENCODE_APP_DIR, "index.html") + const indexFile = Bun.file(indexPath) + if (await indexFile.exists()) { + return new Response(indexFile, { + headers: { + "Content-Type": "text/html", + "Content-Security-Policy": + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'", + }, + }) + } + + return c.notFound() + } + + // Fall back to proxying to app.opencode.ai + const response = await proxy(`https://app.opencode.ai${reqPath}`, { ...c.req, headers: { ...c.req.raw.headers,