diff --git a/.github/last-synced-tag b/.github/last-synced-tag index 0fcdf660062..ed5b170a85d 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.0.218 +v1.0.220 diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md deleted file mode 100644 index 21cfc6a16e0..00000000000 --- a/.opencode/agent/docs.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -description: ALWAYS use this when writing docs -color: "#38A3EE" ---- - -You are an expert technical documentation writer - -You are not verbose - -Use a relaxed and friendly tone - -The title of the page should be a word or a 2-3 word phrase - -The description should be one short line, should not start with "The", should -avoid repeating the title of the page, should be 5-10 words long - -Chunks of text should not be more than 2 sentences long - -Each section is separated by a divider of 3 dashes - -The section titles are short with only the first letter of the word capitalized - -The section titles are in the imperative mood - -The section titles should not repeat the term used in the page title, for -example, if the page title is "Models", avoid using a section title like "Add -new models". This might be unavoidable in some cases, but try to avoid it. - -Check out the /packages/web/src/content/docs/docs/index.mdx as an example. - -For JS or TS code snippets remove trailing semicolons and any trailing commas -that might not be needed. - -If you are making a commit prefix the commit message with `docs:` diff --git a/AGENTS.md b/AGENTS.md index d9ce8d392f0..761d19908d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,10 @@ - To test opencode in the `packages/opencode` directory you can run `bun dev` +## SDK + +To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts + ## Tool Calling - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. diff --git a/README.md b/README.md index f4cb51181fe..7d1af2fbece 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ The following PRs have been merged into this fork and are awaiting merge into up | PR | Title | Author | Status | Description | | ----------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------ | +| [#6476](https://github.com/sst/opencode/pull/6476) | Edit suggested changes before applying | [@dmmulroy](https://github.com/dmmulroy) | Open | Press 'e' to edit AI suggestions in your editor before accepting | +| [#6507](https://github.com/sst/opencode/pull/6507) | Optimize Ripgrep.tree() (109x faster) | [@Karavil](https://github.com/Karavil) | Open | 109x performance improvement for large repos by streaming ripgrep output | | [#6360](https://github.com/sst/opencode/pull/6360) | Desktop: Edit Project | [@dbpolito](https://github.com/dbpolito) | Merged | Edit project name, icon color, and custom icon image in desktop sidebar | | [#6368](https://github.com/sst/opencode/pull/6368) | Desktop: Sidebar subsessions support | [@dbpolito](https://github.com/dbpolito) | Open | Expand/collapse subsessions in sidebar with chevron indicators | | [#6372](https://github.com/sst/opencode/pull/6372) | Desktop: Image Preview and Dedupe | [@dbpolito](https://github.com/dbpolito) | Merged | Click user attachments to preview images, dedupe file uploads | @@ -78,7 +80,7 @@ The following PRs have been merged into this fork and are awaiting merge into up | [#140](https://github.com/Latitudes-Dev/shuvcode/pull/140) | Toggle transparent background | [@JosXa](https://github.com/JosXa) | Open | Command palette toggle for transparent TUI background on any theme | | [Branch](https://github.com/ariane-emory/opencode/tree/feat/glob-permissions) | Granular File Permissions | [@ariane-emory](https://github.com/ariane-emory) | N/A | Glob pattern support for `permission.edit` to restrict agent file access | -_Last updated: 2025-12-29_ +_Last updated: 2025-12-31_ --- diff --git a/STATS.md b/STATS.md index a5174e72e3b..db2e14f7a74 100644 --- a/STATS.md +++ b/STATS.md @@ -186,3 +186,4 @@ | 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | | 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | | 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | +| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | diff --git a/bun.lock b/bun.lock index 6c566d0f754..231751bddb2 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -71,7 +71,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -99,7 +99,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -126,7 +126,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -150,7 +150,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -174,7 +174,7 @@ }, "packages/desktop": { "name": "@shuvcode/desktop", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -202,7 +202,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -231,7 +231,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -247,7 +247,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.218", + "version": "1.0.220", "bin": { "opencode": "./bin/opencode", }, @@ -272,6 +272,7 @@ "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@ai-sdk/togetherai": "1.0.30", + "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", @@ -349,7 +350,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -369,7 +370,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.218", + "version": "1.0.220", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -380,7 +381,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -393,7 +394,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -431,7 +432,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "zod": "catalog:", }, @@ -442,7 +443,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.218", + "version": "1.0.220", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -574,6 +575,8 @@ "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="], + "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="], + "@ai-sdk/xai": ["@ai-sdk/xai@2.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wlwO4yRoZ/d+ca29vN8SDzxus7POdnL7GBTyRdSrt6icUF0hooLesauC8qRUC4aLxtqvMEc1YHtJOU7ZnLWbTQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -4188,6 +4191,12 @@ "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], + "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + + "@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + + "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "@apideck/better-ajv-errors/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], diff --git a/nix/hashes.json b/nix/hashes.json index 2f0a3df12bf..29e7e527240 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY=" + "nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA=" } diff --git a/packages/app/package.json b/packages/app/package.json index ff1033c4a66..d736bacc06e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.218", + "version": "1.0.220", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-create-project.tsx b/packages/app/src/components/dialog-create-project.tsx index d406b617fb8..66c5500d2d0 100644 --- a/packages/app/src/components/dialog-create-project.tsx +++ b/packages/app/src/components/dialog-create-project.tsx @@ -363,27 +363,30 @@ export const DialogCreateProject: Component = () => {
diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index 9a620c90342..55fd5ff5ca3 100644 --- a/packages/app/src/components/header.tsx +++ b/packages/app/src/components/header.tsx @@ -59,8 +59,8 @@ export function Header(props: { when={layout.projects.list().length > 0 && params.dir} fallback={ } > @@ -121,6 +121,12 @@ export function Header(props: {
+ {/* Theme and Font first - desktop only */} + + {/* Review toggle - requires session */} + {/* Terminal toggle - always visible on desktop */} + {/* Share - requires session and share enabled */} -
) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 35f2b833d0e..a276731745c 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1140,6 +1140,7 @@ export const PromptInput: Component = (props) => { providerID: currentModel.provider.id, } const agent = currentAgent.name + const variant = local.model.variant.current() if (isShellMode) { sdk.client.session @@ -1167,6 +1168,7 @@ export const PromptInput: Component = (props) => { arguments: args.join(" "), agent, model: `${model.providerID}/${model.modelID}`, + variant, }) .catch((e) => { console.error("Failed to send command", e) @@ -1203,6 +1205,7 @@ export const PromptInput: Component = (props) => { model, messageID, parts: requestParts, + variant, }) .catch((e) => { console.error("Failed to send prompt", e) @@ -1395,9 +1398,14 @@ export const PromptInput: Component = (props) => { - Choose model - {command.keybind("model.choose")} +
+
+ Choose model + {command.keybind("model.choose")} +
+ + {local.model.current()?.provider.name} +
} > @@ -1411,12 +1419,25 @@ export const PromptInput: Component = (props) => { } > {local.model.current()?.name ?? "Select model"} -
+ 0}> + + + + diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx index 4195f8a6ac8..72cbd887b72 100644 --- a/packages/app/src/components/status-bar.tsx +++ b/packages/app/src/components/status-bar.tsx @@ -1,4 +1,4 @@ -import { createMemo, Show, type ParentProps } from "solid-js" +import { createMemo, createSignal, Show, type ParentProps } from "solid-js" import { useSync } from "@/context/sync" import { useGlobalSync } from "@/context/global-sync" import { useServer } from "@/context/server" @@ -6,6 +6,8 @@ import { usePlatform } from "@/context/platform" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Button } from "@opencode-ai/ui/button" import { DialogSelectServer } from "@/components/dialog-select-server" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon } from "@opencode-ai/ui/icon" export function StatusBar(props: ParentProps) { const dialog = useDialog() @@ -13,15 +15,36 @@ export function StatusBar(props: ParentProps) { const sync = useSync() const globalSync = useGlobalSync() const platform = usePlatform() + const [copied, setCopied] = createSignal(false) - const directoryDisplay = createMemo(() => { + const directoryShort = createMemo(() => { const directory = sync.data.path.directory || "" const home = globalSync.data.path.home || "" - const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory + return home && directory.startsWith(home) ? directory.replace(home, "~") : directory + }) + + const directoryDisplay = createMemo(() => { + const short = directoryShort() const branch = sync.data.vcs?.branch return branch ? `${short}:${branch}` : short }) + const fullPath = createMemo(() => { + return sync.data.path.directory || "" + }) + + const copyPath = async () => { + const path = fullPath() + if (!path) return + try { + await navigator.clipboard.writeText(path) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (e) { + console.error("Failed to copy path:", e) + } + } + return (
@@ -49,13 +72,26 @@ export function StatusBar(props: ParentProps) { v{platform.version} - - {directoryDisplay()} - + +
{props.children}
diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx new file mode 100644 index 00000000000..cc2172edd2d --- /dev/null +++ b/packages/app/src/components/welcome-screen.tsx @@ -0,0 +1,240 @@ +import { createEffect, createMemo, createSignal, onCleanup, Show, For } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { AsciiLogo } from "@opencode-ai/ui/logo" +import { Button } from "@opencode-ai/ui/button" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { isHostedEnvironment, hasUrlQueryParam, getUrlQueryParam } from "@/utils/hosted" + +type ServerStatus = { healthy: boolean; version?: string } + +async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) + .catch(() => ({ healthy: false })) +} + +export interface WelcomeScreenProps { + attemptedUrl?: string + onRetry?: () => void +} + +export function WelcomeScreen(props: WelcomeScreenProps) { + const server = useServer() + const platform = usePlatform() + const [store, setStore] = createStore({ + url: "", + connecting: false, + error: "", + status: {} as Record, + }) + + const urlOverride = getUrlQueryParam() + const isLocalhost = () => { + const url = props.attemptedUrl || "" + return url.includes("localhost") || url.includes("127.0.0.1") + } + + const items = createMemo(() => { + const list = server.list + return list.filter((x) => x !== props.attemptedUrl) + }) + + async function refreshHealth() { + const results: Record = {} + await Promise.all( + items().map(async (url) => { + results[url] = await checkHealth(url, platform.fetch) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + if (items().length === 0) return + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + async function handleConnect(url: string, persist = false) { + const normalized = normalizeServerUrl(url) + if (!normalized) return + + setStore("connecting", true) + setStore("error", "") + + const result = await checkHealth(normalized, platform.fetch) + setStore("connecting", false) + + if (!result.healthy) { + setStore("error", "Could not connect to server") + return + } + + if (persist) { + server.add(normalized) + } else { + server.setActive(normalized) + } + props.onRetry?.() + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const value = normalizeServerUrl(store.url) + if (!value) return + await handleConnect(value, true) + } + + return ( +
+
+ + +
+

Welcome to Shuvcode

+

+ {urlOverride + ? `Could not connect to the server at ${urlOverride}` + : "Connect to a Shuvcode server to get started"} +

+
+ + {/* Local Server Section */} +
+
+ +

Local Server

+
+ + +
+

Start a local server by running:

+ shuvcode +

or

+ npx shuvcode +
+
+ + +
+ + {/* Remote Server Section */} +
+
+ +

Remote Server

+
+ +
+
+
+ { + setStore("url", v) + setStore("error", "") + }} + validationState={store.error ? "invalid" : "valid"} + error={store.error} + /> +
+ +
+
+ +

+ Note: Connecting to a remote server means trusting that server with your data. +

+
+ + {/* Saved Servers Section */} + 0}> +
+

Saved Servers

+
+ + {(url) => ( + + )} + +
+
+
+ + {/* Troubleshooting Section */} + +
+ Troubleshooting +
+

+ Server not running: Make sure you have a Shuvcode server running locally or accessible + remotely. +

+

+ CORS blocked: The server must allow requests from{" "} + {location.origin}. Local servers automatically allow + this domain. +

+

+ Mixed content: If connecting to an http:// server from this{" "} + https:// page, your browser may block the connection. Use https:// for remote + servers. +

+
+
+
+ + +

Version: {platform.version}

+
+
+
+ ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index cb7bf9cf737..59704abda2b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -25,9 +25,12 @@ import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { useGlobalSDK } from "./global-sdk" import { ErrorPage, type InitError } from "../pages/error" +import { WelcomeScreen } from "../components/welcome-screen" import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" +import { isHostedEnvironment } from "@/utils/hosted" +import { useServer } from "./server" type State = { ready: boolean @@ -66,17 +69,24 @@ type State = { } } +type ConnectionState = "connecting" | "ready" | "needs_config" | "error" + function createGlobalSync() { const globalSDK = useGlobalSDK() + const server = useServer() const [globalStore, setGlobalStore] = createStore<{ + connectionState: ConnectionState ready: boolean error?: InitError + attemptedUrl?: string path: Path project: Project[] provider: ProviderListResponse provider_auth: ProviderAuthResponse }>({ + connectionState: "connecting", ready: false, + attemptedUrl: undefined, path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: [], provider: { all: [], connected: [], default: {} }, @@ -402,16 +412,46 @@ function createGlobalSync() { } }) + /** + * Probes the server health with a short timeout (2 seconds). + * Used for initial connection to provide quick feedback. + */ + async function probeHealth( + url: string, + healthFn: () => Promise<{ data?: { healthy?: boolean } }>, + ): Promise<{ healthy: boolean }> { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 2000) + + const result = await healthFn() + clearTimeout(timeoutId) + + return { healthy: result.data?.healthy === true } + } catch { + return { healthy: false } + } + } + async function bootstrap() { - const health = await globalSDK.client.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { + setGlobalStore("connectionState", "connecting") + setGlobalStore("attemptedUrl", globalSDK.url) + + // Use a short timeout for the health probe (2 seconds) + const probeResult = await probeHealth(globalSDK.url, () => globalSDK.client.global.health()) + + if (!probeResult.healthy) { + // For hosted environments, show the welcome/configuration screen + if (isHostedEnvironment()) { + setGlobalStore("connectionState", "needs_config") + return + } + // For non-hosted environments, show the error page setGlobalStore( "error", new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`), ) + setGlobalStore("connectionState", "error") return } @@ -452,8 +492,14 @@ function createGlobalSync() { }), ), ]) - .then(() => setGlobalStore("ready", true)) - .catch((e) => setGlobalStore("error", e)) + .then(() => { + setGlobalStore("ready", true) + setGlobalStore("connectionState", "ready") + }) + .catch((e) => { + setGlobalStore("error", e) + setGlobalStore("connectionState", "error") + }) } onMount(() => { @@ -468,6 +514,12 @@ function createGlobalSync() { get error() { return globalStore.error }, + get connectionState() { + return globalStore.connectionState + }, + get attemptedUrl() { + return globalStore.attemptedUrl + }, child, bootstrap, project: { @@ -482,10 +534,18 @@ export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() return ( - + +
+
Connecting to server...
+
+
+ + value.bootstrap()} /> + + - + {props.children}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index f6a5adeb42a..26192d9359e 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,5 +1,5 @@ import { createStore, produce } from "solid-js/store" -import { batch, createEffect, createMemo, onMount } from "solid-js" +import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" @@ -10,6 +10,12 @@ import { applyTheme, DEFAULT_THEME_ID } from "@/theme/apply-theme" import { applyFontWithLoad } from "@/fonts/apply-font" import { getFontById, FONTS } from "@/fonts/font-definitions" +export const REVIEW_PANE = { + DEFAULT_WIDTH: 450, + MIN_WIDTH: 200, + MAX_WIDTH_RATIO: 0.5, +} as const + const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] @@ -36,6 +42,7 @@ type Dialog = "provider" | "model" | "connect" export type LocalProject = Partial & { worktree: string; expanded: boolean } export type ExpandedSessions = Record +export type ReviewDiffStyle = "unified" | "split" export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", @@ -57,7 +64,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { opened: false, state: "pane" as "pane" | "tab", - width: 450, + width: REVIEW_PANE.DEFAULT_WIDTH as number, + diffStyle: "split" as ReviewDiffStyle, }, session: { width: 600, @@ -128,11 +136,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const list = createMemo(() => enriched().flatMap(colorize)) onMount(() => { + // Load project sessions Promise.all( server.projects.list().map((project) => { return globalSync.project.loadSessions(project.worktree) }), ) + + // Normalize persisted review state (ensure opened defaults to false for old/missing state) + if (store.review === undefined || store.review.opened === undefined) { + setStore("review", "opened", false) + } }) createEffect(() => { @@ -204,6 +218,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( opened: createMemo(() => store.review?.opened ?? true), state: createMemo(() => store.review?.state ?? "pane"), width: createMemo(() => store.review?.width ?? 450), + diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), + setDiffStyle(diffStyle: ReviewDiffStyle) { + if (!store.review) { + setStore("review", { opened: true, diffStyle }) + return + } + setStore("review", "diffStyle", diffStyle) + }, open() { setStore("review", "opened", true) }, @@ -226,6 +248,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( session: { width: createMemo(() => store.session?.width ?? 600), resize(width: number) { + // ResizeHandle already enforces min/max constraints if (!store.session) { setStore("session", { width }) } else { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index cefcc9fec4d..793494af3c0 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -115,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ createStore<{ user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] recent: ModelKey[] + variant?: Record }>({ user: [], recent: [], + variant: {}, }), ) @@ -272,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setVisibility(model: ModelKey, visible: boolean) { updateVisibility(model, visible ? "show" : "hide") }, + variant: { + current() { + const m = current() + if (!m) return undefined + const key = `${m.provider.id}/${m.id}` + return store.variant?.[key] + }, + list() { + const m = current() + if (!m) return [] + if (!m.variants) return [] + return Object.keys(m.variants) + }, + set(value: string | undefined) { + const m = current() + if (!m) return + const key = `${m.provider.id}/${m.id}` + if (!store.variant) { + setStore("variant", { [key]: value }) + } else { + setStore("variant", key, value) + } + }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + const currentVariant = this.current() + if (!currentVariant) { + this.set(variants[0]) + return + } + const index = variants.indexOf(currentVariant) + if (index === -1 || index === variants.length - 1) { + this.set(undefined) + return + } + this.set(variants[index + 1]) + }, + }, } })() diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index c77b027ec7d..2b3a8d4349c 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -39,10 +39,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const platform = usePlatform() const [store, setStore, _, ready] = persisted( - "server.v3", + "server.v4", createStore({ list: [] as string[], projects: {} as Record, + active: "" as string, // Persist the last active server }), ) @@ -51,7 +52,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( function setActive(input: string) { const url = normalizeServerUrl(input) if (!url) return - setActiveRaw(url) + batch(() => { + setActiveRaw(url) + setStore("active", url) // Persist active server + }) } function add(input: string) { @@ -60,7 +64,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const fallback = normalizeServerUrl(props.defaultUrl) if (fallback && url === fallback) { - setActiveRaw(url) + batch(() => { + setActiveRaw(url) + setStore("active", url) + }) return } @@ -69,6 +76,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( setStore("list", store.list.length, url) } setActiveRaw(url) + setStore("active", url) }) } @@ -82,13 +90,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( batch(() => { setStore("list", list) setActiveRaw(next) + setStore("active", next) }) } + // Initialize active server from persisted state or default createEffect(() => { if (!ready()) return if (active()) return - const url = normalizeServerUrl(props.defaultUrl) + // Priority: persisted active > default URL + const persistedActive = store.active ? normalizeServerUrl(store.active) : undefined + const url = persistedActive || normalizeServerUrl(props.defaultUrl) if (!url) return setActiveRaw(url) }) @@ -120,10 +132,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const origin = createMemo(() => projectsKey(active())) const projectsList = createMemo(() => store.projects[origin()] ?? []) + const isLocal = createMemo(() => origin() === "local") return { ready: isReady, healthy, + isLocal, get url() { return active() }, diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 700f8a14048..b225bdd6be9 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -88,7 +88,7 @@ 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: 100 })), + retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })), retry(() => sdk.client.session.todo({ sessionID })), retry(() => sdk.client.session.diff({ sessionID })), ]) diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 70a5a9f23a8..ba1b9cd2654 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -2,6 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import { AsciiLogo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Component, Show } from "solid-js" +import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Icon } from "@opencode-ai/ui/icon" @@ -181,6 +182,25 @@ interface ErrorPageProps { export const ErrorPage: Component = (props) => { const platform = usePlatform() + const [store, setStore] = createStore({ + checking: false, + version: undefined as string | undefined, + }) + + async function checkForUpdates() { + if (!platform.checkUpdate) return + setStore("checking", true) + const result = await platform.checkUpdate() + setStore("checking", false) + if (result.updateAvailable && result.version) setStore("version", result.version) + } + + async function installUpdate() { + if (!platform.update || !platform.restart) return + await platform.update() + await platform.restart() + } + return (
@@ -198,9 +218,25 @@ export const ErrorPage: Component = (props) => { label="Error Details" hideLabel /> - +
+ + + + {store.checking ? "Checking..." : "Check for updates"} + + } + > + + + +
Please report this error to the shuvcode team diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 67e6b132252..86d00d1b498 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -17,6 +17,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" +import { Mark } from "@opencode-ai/ui/logo" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" @@ -37,6 +38,7 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { PullToRefresh } from "@/components/pull-to-refresh" + import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" @@ -49,6 +51,7 @@ import { applyTheme } from "@/theme/apply-theme" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { useServer } from "@/context/server" import { Header } from "@/components/header" export default function Layout(props: ParentProps) { @@ -84,6 +87,7 @@ export default function Layout(props: ParentProps) { const globalSync = useGlobalSync() const layout = useLayout() const platform = usePlatform() + const server = useServer() const notification = useNotification() const navigate = useNavigate() const providers = useProviders() @@ -473,7 +477,6 @@ export default function Layout(props: ParentProps) { else navigate("/") } - createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) @@ -1038,9 +1041,11 @@ export default function Layout(props: ParentProps) { Share feedback - + + +
v{__APP_VERSION__} ({__COMMIT_HASH__})
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c45830bea07..52b2fa70807 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -42,7 +42,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" -import { useLayout } from "@/context/layout" +import { useLayout, REVIEW_PANE } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" @@ -205,7 +205,6 @@ export default function Page() { stepsExpanded: true, mobileTabsOpen: false, mobileTerminalFullscreen: false, - diffSplit: false, }) let inputRef!: HTMLDivElement @@ -773,6 +772,7 @@ export default function Page() { ) } + const hasReviewContent = createMemo(() => diffs().length > 0 || tabs().all().length > 0) const showTabs = createMemo(() => layout.review.opened()) const tabsValue = createMemo(() => tabs().active() ?? "review") @@ -879,7 +879,7 @@ export default function Page() { direction="horizontal" size={layout.session.width()} min={320} - max={window.innerWidth * 0.7} + max={window.innerWidth - REVIEW_PANE.MIN_WIDTH} onResize={layout.session.resize} />
@@ -941,83 +941,88 @@ export default function Page() {
- - -
- setStore("diffSplit", (x) => !x)} - > - {store.diffSplit ? "Inline" : "Split"} - - } - /> + +
+ +
No files to review
+
Changes will appear here
+
-
-
- - {(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - - - {(content) => { - const f = file()! - const isPreviewableImage = - content.encoding === "base64" && - content.mimeType?.startsWith("image/") && - content.mimeType !== "image/svg+xml" - return ( - - -
- {f.path} -
-
- -
- -
-
-
- ) + } + > + + +
+ - - ) - }} - + diffs={diffs()} + diffStyle={layout.review.diffStyle()} + onDiffStyleChange={layout.review.setDiffStyle} + /> +
+
+
+ + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + + + {(content) => { + const f = file()! + const isPreviewableImage = + content.encoding === "base64" && + content.mimeType?.startsWith("image/") && + content.mimeType !== "image/svg+xml" + return ( + + +
+ {f.path} +
+
+ +
+ +
+
+
+ ) + }} +
+
+ ) + }} +
+
@@ -1220,16 +1225,8 @@ export default function Page() { container: "px-4", }} diffs={diffs()} - split={store.diffSplit} - actions={ - - } + diffStyle={layout.review.diffStyle()} + onDiffStyleChange={layout.review.setDiffStyle} />
diff --git a/packages/app/src/utils/hosted.ts b/packages/app/src/utils/hosted.ts new file mode 100644 index 00000000000..94c4c9b9c91 --- /dev/null +++ b/packages/app/src/utils/hosted.ts @@ -0,0 +1,25 @@ +/** + * Checks if the app is running in a hosted environment (app.shuv.ai or app.opencode.ai). + * In hosted environments, users need to configure their server connection. + */ +export function isHostedEnvironment(): boolean { + if (typeof window === "undefined") return false + return location.hostname.includes("opencode.ai") || location.hostname.includes("shuv.ai") +} + +/** + * Checks if a ?url= query parameter was provided in the URL. + * This indicates the user is trying to connect to a specific server. + */ +export function hasUrlQueryParam(): boolean { + if (typeof window === "undefined") return false + return new URLSearchParams(document.location.search).has("url") +} + +/** + * Gets the ?url= query parameter value if present. + */ +export function getUrlQueryParam(): string | null { + if (typeof window === "undefined") return null + return new URLSearchParams(document.location.search).get("url") +} diff --git a/packages/app/test/hosted.test.ts b/packages/app/test/hosted.test.ts new file mode 100644 index 00000000000..09d1620416e --- /dev/null +++ b/packages/app/test/hosted.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" + +// Note: These tests require the happy-dom environment set up via bunfig.toml + +describe("hosted.ts utilities", () => { + let originalHostname: string + let originalSearch: string + + beforeEach(() => { + originalHostname = window.location.hostname + originalSearch = window.location.search + }) + + afterEach(() => { + // Reset location properties (happy-dom allows this) + Object.defineProperty(window.location, "hostname", { + value: originalHostname, + writable: true, + }) + Object.defineProperty(window.location, "search", { + value: originalSearch, + writable: true, + }) + }) + + describe("isHostedEnvironment", () => { + test("returns true for opencode.ai domains", async () => { + Object.defineProperty(window.location, "hostname", { + value: "app.opencode.ai", + writable: true, + }) + + // Dynamic import to get fresh evaluation + const { isHostedEnvironment } = await import("../src/utils/hosted") + expect(isHostedEnvironment()).toBe(true) + }) + + test("returns true for shuv.ai domains", async () => { + Object.defineProperty(window.location, "hostname", { + value: "app.shuv.ai", + writable: true, + }) + + const { isHostedEnvironment } = await import("../src/utils/hosted") + expect(isHostedEnvironment()).toBe(true) + }) + + test("returns false for localhost", async () => { + Object.defineProperty(window.location, "hostname", { + value: "localhost", + writable: true, + }) + + const { isHostedEnvironment } = await import("../src/utils/hosted") + expect(isHostedEnvironment()).toBe(false) + }) + + test("returns false for other domains", async () => { + Object.defineProperty(window.location, "hostname", { + value: "example.com", + writable: true, + }) + + const { isHostedEnvironment } = await import("../src/utils/hosted") + expect(isHostedEnvironment()).toBe(false) + }) + }) + + describe("hasUrlQueryParam", () => { + test("returns true when ?url= parameter exists", async () => { + Object.defineProperty(window.location, "search", { + value: "?url=http://localhost:4096", + writable: true, + }) + + const { hasUrlQueryParam } = await import("../src/utils/hosted") + expect(hasUrlQueryParam()).toBe(true) + }) + + test("returns false when no ?url= parameter", async () => { + Object.defineProperty(window.location, "search", { + value: "", + writable: true, + }) + + const { hasUrlQueryParam } = await import("../src/utils/hosted") + expect(hasUrlQueryParam()).toBe(false) + }) + + test("returns false when other parameters exist but not ?url=", async () => { + Object.defineProperty(window.location, "search", { + value: "?foo=bar&baz=qux", + writable: true, + }) + + const { hasUrlQueryParam } = await import("../src/utils/hosted") + expect(hasUrlQueryParam()).toBe(false) + }) + }) + + describe("getUrlQueryParam", () => { + test("returns the URL value when present", async () => { + Object.defineProperty(window.location, "search", { + value: "?url=http://localhost:4096", + writable: true, + }) + + const { getUrlQueryParam } = await import("../src/utils/hosted") + expect(getUrlQueryParam()).toBe("http://localhost:4096") + }) + + test("returns null when not present", async () => { + Object.defineProperty(window.location, "search", { + value: "", + writable: true, + }) + + const { getUrlQueryParam } = await import("../src/utils/hosted") + expect(getUrlQueryParam()).toBeNull() + }) + + test("handles URL-encoded values", async () => { + Object.defineProperty(window.location, "search", { + value: "?url=https%3A%2F%2Fmy-server.example.com%3A8080", + writable: true, + }) + + const { getUrlQueryParam } = await import("../src/utils/hosted") + expect(getUrlQueryParam()).toBe("https://my-server.example.com:8080") + }) + }) +}) diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 2b4bb72e6aa..36c00e3ff74 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.218", + "version": "1.0.220", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index 99b5939669a..b099e900e6b 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -1,5 +1,5 @@ import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router" -import { createEffect, Show } from "solid-js" +import { createEffect, Show, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" @@ -68,6 +68,12 @@ export function ReloadSection() { reloadTrigger: "", }) + const processingFee = createMemo(() => { + const reloadAmount = billingInfo()?.reloadAmount + if (!reloadAmount) return "0.00" + return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2) + }) + createEffect(() => { if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) { setStore("show", false) @@ -104,8 +110,8 @@ export function ReloadSection() { } >

- Auto reload is enabled. We'll reload ${billingInfo()?.reloadAmount} (+$1.23 processing fee) - when balance reaches ${billingInfo()?.reloadTrigger}. + Auto reload is enabled. We'll reload ${billingInfo()?.reloadAmount} (+${processingFee()}{" "} + processing fee) when balance reaches ${billingInfo()?.reloadTrigger}.