Compare commits

...

10 Commits

Author SHA1 Message Date
Brendan Allan 4d22d4e75f fix(desktop): use Effect platform subpath imports (#43817) 2026-08-21 15:32:55 +08:00
Aiden Cline 8b93bc395d feat(core): allow plan mode to write/edit PLAN files exclusively (#43710) 2026-08-21 02:29:50 -05:00
Aiden Cline e756e497c2 fix(ai): merge parallel gemini tool results into one turn (#43814) 2026-08-21 02:27:40 -05:00
Aiden Cline 0d2684b673 fix(ai): align Vertex Anthropic version headers (#43804) 2026-08-21 01:49:30 -05:00
opencode-agent[bot] 858caa6848 fix(ai): classify network error variants (#43807)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 01:47:34 -05:00
Brendan Allan 9a89851cea fix(app): preserve timeline bottom after session switch (#43801) 2026-08-21 06:32:25 +00:00
Brendan Allan 876459788f feat(app): warn about V1 servers (#43790) 2026-08-21 06:30:58 +00:00
Aiden Cline b0ab1e2992 fix(core): forward PDF prompt attachments (#43799) 2026-08-21 01:16:05 -05:00
Aiden Cline d158f2cd39 fix(ai): scrub Anthropic tool call IDs (#43796) 2026-08-21 00:54:31 -05:00
Luke Parker 9be3aa92b5 refactor(app): organize product slices (#43789) 2026-08-21 15:48:04 +10:00
456 changed files with 2803 additions and 4587 deletions
-1
View File
@@ -27,7 +27,6 @@
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
@@ -374,16 +374,18 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "tool" as const, name }),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
type: "tool_use",
id: part.id,
id: scrubToolCallID(part.id),
name: part.name,
input: part.input,
})
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
type: "server_tool_use",
id: part.id,
id: scrubToolCallID(part.id),
name: part.name,
input: part.input,
})
@@ -405,7 +407,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@@ -587,7 +589,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
content.push({
type: "tool_result",
tool_use_id: part.id,
tool_use_id: scrubToolCallID(part.id),
content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
+6 -1
View File
@@ -379,7 +379,12 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
},
})
}
contents.push({ role: "user", parts })
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
else contents.push({ role: "user", parts })
}
return contents
+2
View File
@@ -74,6 +74,7 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
@@ -127,6 +128,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
@@ -13,6 +13,7 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
const HEADER_VERSION = "2023-06-01" as const
export const id = ProviderID.make("google-vertex")
@@ -57,6 +58,7 @@ const route = Route.make({
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
export const routes = [route]
+8
View File
@@ -82,6 +82,14 @@ describe("provider error classification", () => {
])
})
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
@@ -327,6 +327,29 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("scrubs outbound tool call IDs without truncating them", () =>
Effect.gen(function* () {
const id = `functions.lookup:1|${"x".repeat(64)}`
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "done" }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
])
expect(scrubbed.length).toBeGreaterThan(64)
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1393,14 +1416,14 @@ describe("Anthropic Messages route", () => {
Message.assistant([
{
type: "tool-call",
id: "srvtoolu_abc",
id: "srvtoolu.abc",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
},
{
type: "tool-result",
id: "srvtoolu_abc",
id: "srvtoolu.abc",
name: "web_search",
result: { type: "json", value: [{ url: "https://example.com" }] },
providerExecuted: true,
+47
View File
@@ -181,6 +181,53 @@ describe("Gemini route", () => {
}),
)
it.effect("merges parallel tool results into one function-response turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "noon" },
},
},
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -96,7 +96,7 @@ describe("Google Vertex providers", () => {
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(request.headers.get("anthropic-version")).toBeNull()
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({
anthropic_version: "vertex-2023-10-16",
@@ -22,7 +22,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog")
const dialog = page.locator(".settings-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -63,7 +63,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
+4 -4
View File
@@ -6,10 +6,10 @@
"exports": {
".": "./src/index.ts",
"./desktop": "./src/desktop.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
+15 -105
View File
@@ -4,72 +4,26 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import {
type Component,
createMemo,
createRenderEffect,
ErrorBoundary,
type JSX,
lazy,
type ParentProps,
Show,
} from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { requireServerKey } from "./utils/session-route"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
import { GlobalProvider } from "@/runtime/server/runtime"
import { HighlightsProvider } from "@/shell/updates/highlights"
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { WslServersProvider } from "@/servers/wsl/context"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const conn = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
)
}
export { preloadRoute }
declare global {
interface Window {
__OPENCODE__?: {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
@@ -100,39 +54,6 @@ function BodyTypography() {
return null
}
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: language.t("command.logs.export"),
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
<Layout>{props.children}</Layout>
</LayoutProvider>
)
}
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
@@ -204,18 +125,7 @@ export function AppInterface(props: {
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<AppRoutes />
</Dynamic>
</GlobalProvider>
</SettingsProvider>
@@ -1,261 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { ServerCollectionController } from "@/components/server/server-management-controller"
type ServerConnectionFormController = {
state: {
adding: () => boolean
busy: () => boolean
value: () => string
name: () => string
username: () => string
password: () => string
error: () => string
status: () => boolean | undefined
}
change: {
value: (value: string) => void
name: (value: string) => void
username: (value: string) => void
password: (value: string) => void
}
reset: () => void
submit: () => void
}
interface ServerFormProps {
value: string
name: string
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onBack: () => void
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.onBack()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
props.onSubmit()
}
return (
<div>
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
type="text"
label={language.t("dialog.server.add.url")}
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={keyDown}
/>
</div>
<TextField
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
defaultValue={props.name}
disabled={props.busy}
onChange={props.onNameChange}
onKeyDown={keyDown}
/>
<div class="grid grid-cols-2 gap-2 min-w-0">
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
defaultValue={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
defaultValue={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
)
}
export function ServerConnectionList(props: {
domain: ServerCollectionController
onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
}) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<List
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items}
key={(x) => x.http.url}
divider={true}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div>
<ServerRow
conn={i}
dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={i.type === "http"}>
<Menu appearance="standard">
<Menu.Trigger
as={IconButton}
icon={<Icon name="dot-grid" />}
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<Menu.Portal>
<Menu.Content class="mt-1">
<Menu.Item
onSelect={() => {
if (i.type !== "http") return
props.onEdit(i)
}}
>
{language.t("dialog.server.menu.edit")}
</Menu.Item>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<Menu.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")}
</Menu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</Menu.Item>
</Show>
<Show when={props.domain.connection.canRemove(key)}>
<Menu.Separator />
<Menu.Item
onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
{language.t("dialog.server.menu.delete")}
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
</div>
)
}}
</List>
<div class="shrink-0 pb-5">
<Button
variant="neutral"
icon="plus-small"
size="large"
onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
</div>
</div>
)
}
export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.form.state.value()}
name={props.form.state.name()}
username={props.form.state.username()}
password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.form.state.busy()}
error={props.form.state.error()}
status={props.form.state.status()}
onChange={props.form.change.value}
onNameChange={props.form.change.name}
onUsernameChange={props.form.change.username}
onPasswordChange={props.form.change.password}
onSubmit={props.form.submit}
onBack={props.form.reset}
/>
<div class="shrink-0 pb-5">
<Button
variant="contrast"
size="large"
onClick={props.form.submit}
disabled={props.form.state.busy()}
class="px-3 py-1.5"
>
{props.form.state.busy()
? language.t("dialog.server.add.checking")
: props.form.state.adding()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</div>
</div>
)
}
@@ -1,5 +0,0 @@
import { type Component, type JSX } from "solid-js"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
}
@@ -1 +0,0 @@
export { DialogSettings } from "./dialog-settings-v2"
@@ -1,6 +0,0 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-v2-list">{props.children}</div>
}
@@ -1,20 +0,0 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export interface SettingsRowV2Props {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
return (
<div data-component="settings-v2-row">
<div data-slot="settings-v2-row-copy">
<div data-slot="settings-v2-row-title">{props.title}</div>
<div data-slot="settings-v2-row-description">{props.description}</div>
</div>
<div data-slot="settings-v2-row-control">{props.children}</div>
</div>
)
}
-119
View File
@@ -1,119 +0,0 @@
/**
* Taken from https://www.solid-ui.com/docs/components/drawer
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
*/
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
import DrawerPrimitive from "@corvu/drawer"
const Drawer = DrawerPrimitive
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
const drawerContext = DrawerPrimitive.useContext()
const overlayStyle = () => {
const state = drawerContext.transitionState()
if (state === "opening" || state === "closing") return undefined
const open = drawerContext.openPercentage()
return {
opacity: open,
"backdrop-filter": `blur(${4 * open}px)`,
}
}
return (
<DrawerPrimitive.Overlay
class={props.class}
classList={{
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
}}
style={overlayStyle()}
{...rest}
/>
)
}
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
class?: string
children?: JSX.Element
}
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={props.class}
classList={{
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}}
{...rest}
>
{props.children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
}
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
}
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
return (
<DrawerPrimitive.Label
class={props.class}
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
{...rest}
/>
)
}
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
class?: string
}
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
return (
<DrawerPrimitive.Description
class={props.class}
classList={{
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
}}
{...rest}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Data } from "@opencode-ai/client/solid"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/context/local"
import type { ServerSDK } from "@/context/server-sdk"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
import type { ComposerStateTarget } from "./submission-state"
import type { createComposerSubmission } from "./submission-state"
+4 -4
View File
@@ -1,6 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useCommand, type CommandOption } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
@@ -40,7 +40,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
if (cursor !== null) setCursorPosition(editor, cursor)
})
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
})
@@ -1,4 +1,4 @@
import type { FileSelection } from "@/context/file"
import type { FileSelection } from "@/workspaces/files/model"
export type PromptComment = {
path: string
@@ -3,13 +3,13 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/util/encode"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
import { useWorkspaceLocation } from "./location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { useServerSDK } from "@/runtime/server/client"
import type { ServerScope } from "@/runtime/server/scope"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import { uuid } from "@/runtime/persistence/uuid"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
export type LineComment = {
id: string
@@ -1,6 +1,6 @@
import { Show, createMemo, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import type { ModelSelection } from "@/context/local"
import type { ModelSelection } from "@/providers/models/selection"
import { STORY_MODEL, emptySessionDocument, pendingAndQueuedDocument } from "@opencode-ai/session-ui/storybook"
import { Composer } from "./composer"
import type { ComposerModel } from "./model"
+4 -4
View File
@@ -6,10 +6,10 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/context/file"
import type { SelectedLineRange } from "@/workspaces/files/model"
export const MAX_HISTORY = 100
+1 -1
View File
@@ -1,6 +1,6 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/utils/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
clonePromptParts,
+10 -10
View File
@@ -4,17 +4,17 @@ import type { ReferenceInfo } from "@opencode-ai/client/promise"
import { createComponent, createEffect, createMemo, on } from "solid-js"
import type { ComposerSuggestion } from "./types"
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspaces/files/model"
import { useComments } from "@/composer/comments"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/utils/toast"
import { formatServerError } from "@/utils/server-errors"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
+6 -6
View File
@@ -2,12 +2,12 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs, type Tab } from "@/context/tabs"
import type { ServerScope } from "@/utils/server-scope"
import { requireServerKey } from "@/shell/routes/session"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useTabs, type Tab } from "@/shell/tabs/tabs"
import type { ServerScope } from "@/runtime/server/scope"
import {
createComposerReady,
createComposerState,
@@ -1,5 +1,5 @@
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { createLegacyBlobReference } from "@/utils/draft-store"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { readPromptPresentation } from "./comment-note"
import { Skill } from "@opencode-ai/schema/skill"
+3 -3
View File
@@ -1,8 +1,8 @@
import { getFilename } from "@opencode-ai/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
+8 -67
View File
@@ -1,19 +1,13 @@
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import type { ComposerControls } from "./adapter"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal, useServerCtx } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/context/local"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServers } from "@/context/servers"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs } from "@/context/tabs"
import { useProviders } from "@/hooks/use-providers"
import { useData } from "@/context/server"
import { normalizeAgentList } from "@/context/global-sync/utils"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
@@ -159,56 +153,3 @@ export function createComposerModelSelection(input: {
return selection
}
export function createComposerProjectControls(props: { draftId: string }) {
const server = useServers()
const serverSDK = useServerSDK()
const sdk = useWorkspaceLocation()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const projectServer = () => serverSDK.server
const projectServerCtx = useServerCtx(projectServer)
const projects = createMemo(() => {
if (server.list.length <= 1) {
return projectServerCtx().projects.list()
}
return server.list.flatMap((conn) => {
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
return global
.ensureServerCtx(conn)
.projects.list()
.map((project) => ({ ...project, server: item }))
})
})
const selectProject = (worktree: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(props.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
}
const addProject = (title: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (!conn) return
pickDirectory({
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory, serverKey)
},
})
}
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: sdk().directory,
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
}))
}
+5 -5
View File
@@ -1,11 +1,11 @@
import { checksum } from "@opencode-ai/util/encode"
import { batch, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { ServerScope } from "@/utils/server-scope"
import type { BlobReference } from "@/utils/draft-store"
import type { Platform } from "@/context/platform"
import type { FileSelection } from "@/workspaces/files/model"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerScope } from "@/runtime/server/scope"
import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/context/local"
import type { ModelSelection } from "@/providers/models/selection"
import { Skill } from "@opencode-ai/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
+1 -1
View File
@@ -7,7 +7,7 @@ import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adap
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/utils/draft-store"
import { blobDataUrl } from "@/runtime/persistence/drafts"
const submitting = new WeakSet<object>()
View File
@@ -1,51 +0,0 @@
import { expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { ServerScope } from "@/utils/server-scope"
import { createCatalogSync } from "./catalog"
import { pathKey } from "@/utils/path-key"
test("invalidates the catalog for the event location", async () => {
const queryClient = new QueryClient()
const one = [ServerScope.local, "/one", "providers"] as const
const integrations = [ServerScope.local, "/one", "integrations"] as const
const two = [ServerScope.local, "/two", "providers"] as const
queryClient.setQueryData(one, { providers: ["one"] })
queryClient.setQueryData(integrations, { integrations: ["one"] })
queryClient.setQueryData(two, { providers: ["two"] })
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/one"), pathKey("/two")],
load: async () => {},
})
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
await Bun.sleep(0)
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
})
test("invalidates global and active catalogs after connection", async () => {
const queryClient = new QueryClient()
const global = [ServerScope.local, null, "providers"] as const
const active = [ServerScope.local, "/active", "providers"] as const
const passive = [ServerScope.local, "/passive", "providers"] as const
queryClient.setQueryData(global, {})
queryClient.setQueryData(active, {})
queryClient.setQueryData(passive, {})
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/active")],
load: async () => {},
})
catalog.handleEvent({ type: "server.connected" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
})
@@ -1,53 +0,0 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory?: string
}
export function createCatalogSync(input: {
scope: ServerScope
queryClient: QueryClient
active: () => PathKey[]
load: (directory: PathKey | null) => Promise<void>
}) {
function handleEvent(event: CatalogEvent) {
if (event.type === "server.connected") {
void refreshActive().catch(() => undefined)
return
}
if (
event.type === "catalog.updated" ||
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
}
}
async function refresh(directory: PathKey | null) {
await Promise.all(
["providers", "integrations"].map((resource) =>
input.queryClient.invalidateQueries({
queryKey: [input.scope, directory, resource],
exact: true,
refetchType: "none",
}),
),
)
await input.load(directory)
}
function refreshActive() {
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
}
return {
handleEvent,
refresh,
refreshActive,
}
}
+9 -8
View File
@@ -1,9 +1,10 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
export { type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection, useServers } from "./context/servers"
export { useTabs } from "./context/tabs"
export { createDraftStore } from "./utils/draft-store"
export { useWslServers } from "./wsl/context"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { useWslServers } from "./servers/wsl/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
+13 -101
View File
@@ -1,18 +1,16 @@
// @refresh reload
import * as Sentry from "@sentry/solid"
import { init } from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { authFromToken } from "@/utils/server"
import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
import pkg from "../package.json"
import { ServerConnection } from "./context/servers"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
import { ServerConnection } from "@/runtime/server/registry"
const getLocale = () => {
if (typeof navigator !== "object") return "en" as const
@@ -30,85 +28,11 @@ const getRootNotFoundError = () => {
return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
}
const getStorage = (key: string) => {
if (typeof localStorage === "undefined") return null
try {
return localStorage.getItem(key)
} catch {
return null
}
}
const setStorage = (key: string, value: string | null) => {
if (typeof localStorage === "undefined") return
try {
if (value !== null) {
localStorage.setItem(key, value)
return
}
localStorage.removeItem(key)
} catch {
return
}
}
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
const notify: Platform["notify"] = async (title, description, onClick) => {
if (!("Notification" in window)) return
const permission =
Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
if (permission !== "granted") return
const inView = document.visibilityState === "visible" && document.hasFocus()
if (inView) return
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
window.focus()
onClick?.()
notification.close()
}
}
const openExternal: Platform["openExternal"] = (value) => {
if (!URL.canParse(value)) return
const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
window.open(url.href, "_blank", "noopener,noreferrer")
}
const restart: Platform["restart"] = async () => {
window.location.reload()
}
const root = document.getElementById("root")
if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
const getDefaultUrl = () => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
return getCurrentUrl()
}
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
@@ -116,22 +40,10 @@ const clearAuthToken = () => {
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const platform: Platform = {
platform: "web",
draftStore: createBrowserDraftStore(),
version: pkg.version,
openExternal,
restart,
notify,
getDefaultServer: async () => {
const stored = readDefaultServerUrl()
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
}
const web = createWebPlatform(pkg.version)
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`,
@@ -157,16 +69,16 @@ if (root instanceof HTMLElement) {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
url: web.currentServerUrl,
...auth,
},
}
render(
() => (
<PlatformProvider value={platform}>
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
/>
@@ -1,8 +1,8 @@
import { useGlobal, useServerCtx } from "@/context/global"
import { type HomeProjectSelection, useLayout } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
@@ -1,18 +1,18 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/servers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useServerActionsController } from "@/servers/registry/controller"
import { useSettingsCommand } from "@/settings/command"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { useGlobal } from "@/context/global"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
export function createHomeProjectsController(home: HomeController) {
const platform = usePlatform()
@@ -62,8 +62,8 @@ export function createHomeProjectsController(home: HomeController) {
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => {
void import("@/components/settings-v2/dialog-server-v2").then(({ DialogServerV2 }) => {
void dialog.show(() => <DialogServerV2 mode="edit" server={conn} />)
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
focus: home.selection.focusServer,
@@ -76,8 +76,8 @@ export function createHomeProjectsController(home: HomeController) {
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
@@ -1,6 +1,6 @@
import type { HomeProjectsController } from "./home-projects-controller"
import { HomeProjectsView } from "./home-projects-view"
import type { HomeScrollController } from "./home-scroll-controller"
import type { HomeProjectsController } from "./controller"
import { HomeProjectsView } from "./view"
import type { HomeScrollController } from "../scroll"
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
return (
@@ -11,15 +11,15 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { fileManagerApp } from "@/utils/file-manager"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { type ServerHealth } from "@/runtime/server/health"
import { fileManagerApp } from "@/home/projects/file-manager"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
@@ -196,6 +196,7 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
onCleanup(() => {
@@ -203,96 +204,107 @@ function HomeServerRow(props: {
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
})
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<Tooltip
appearance="standard"
placement="top"
class="flex h-7 w-full min-w-0"
inactive={!incompatible()}
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
type="button"
class="pr-16 disabled:opacity-60"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class={`
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class={`
-ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
rounded-[4px] text-v2-icon-icon-muted
`}
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
<Icon
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span
class={`
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
<Icon
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed || !canToggle() ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>
{props.server.displayName ?? new URL(props.server.http.url).host}
</span>
<Show when={props.server.label}>
{(label) => (
<span
class={`
shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
text-[9px] leading-none text-v2-text-text-muted
`}
>
{label()}
</span>
)}
</Show>
</span>
</HomeProjectNavButton>
<div
class={`
>
{label()}
</span>
)}
</Show>
</span>
</HomeProjectNavButton>
<div
class={`
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
data-menu={props.contextMenuOpen(contextMenuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
</Tooltip>
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
/>
</Tooltip>
</div>
</div>
</div>
</Tooltip>
)
}
@@ -1,12 +1,12 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createHomeController } from "./home/home-controller"
import { createHomeProjectsController } from "./home/home-projects-controller"
import { HomeUtilityNav } from "./home/home-projects-view"
import { HomeProjects } from "./home/home-projects"
import { createHomeScrollController } from "./home/home-scroll-controller"
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
import { createHomeSessionsController } from "./home/home-sessions-controller"
import { HomeSessions } from "./home/home-sessions"
import { createHomeController } from "./model"
import { createHomeProjectsController } from "./projects/controller"
import { HomeUtilityNav } from "./projects/view"
import { HomeProjects } from "./projects/region"
import { createHomeScrollController } from "./scroll"
import { createHomeSessionSearchController } from "./sessions/search"
import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
export function Home() {
const home = createHomeController()
@@ -1,6 +1,6 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeSessionGroup } from "./home-sessions-controller"
import type { HomeSessionGroup } from "./sessions/controller"
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { archiveHomeSession } from "./home-session-archive"
import type { ServerConnection } from "@/context/servers"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
import { archiveHomeSession } from "./archive"
import type { ServerConnection } from "@/runtime/server/registry"
const remote = "remote" as ServerConnection.Key
@@ -1,5 +1,5 @@
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import type { ServerConnection } from "@/context/servers"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import type { ServerConnection } from "@/runtime/server/registry"
import type { SessionInfo } from "@opencode-ai/client/promise"
type HomeSession = Pick<SessionInfo, "id" | "location">
@@ -0,0 +1,74 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand } from "@/shell/commands/command"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import {
createCommandPaletteCommandEntry,
createServerSessionEntries,
type CommandPaletteEntry,
} from "@/shell/commands/palette"
import { CommandPaletteView, matchesCommandPaletteEntry } from "@/shell/commands/dialog"
export function HomeCommandPalette(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const server = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: server.projects.list,
stored: () => server.sync.data.project,
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
@@ -3,25 +3,21 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { useCommand } from "@/context/command"
import {
loadHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { errorMessage } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeController } from "./home-controller"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./home-session-records"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./home-session-records"
export type { HomeSessionRecord } from "./records"
const HOME_SESSION_LIMIT = 64
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
@@ -105,9 +101,9 @@ export function createHomeSessionsController(home: HomeController) {
if (!conn) return
const ctx = home.server.focusedContext()
if (!ctx) return
const { DialogHomeCommandPalette } = await import("@/components/dialog-command-palette")
const { HomeCommandPalette } = await import("./command-palette")
void dialog.show(() => (
<DialogHomeCommandPalette
<HomeCommandPalette
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
@@ -144,14 +140,13 @@ export function createHomeSessionsController(home: HomeController) {
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
const project =
home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const project = home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
@@ -1,11 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import {
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
parseHomeSessionIndex,
retainHomeSessions,
} from "./home-session-index"
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
const session = (id: string, input: Partial<SessionInfo> = {}) =>
({
@@ -17,7 +12,7 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
...input,
}) as SessionInfo
describe("Home V2 session index", () => {
describe("Home session index", () => {
test("loads all pages", async () => {
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
const calls: Array<{ cursor?: string; parentID: null }> = []
@@ -1,6 +1,6 @@
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
import { pathKey } from "@/utils/path-key"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
import { pathKey } from "@/workspaces/path-key"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { shouldOpenSessionInBackground } from "./open"
describe("shouldOpenSessionInBackground", () => {
test("opens middle clicks in the background", () => {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/context/layout"
import { buildHomeSessionRecords } from "./home-session-records"
import type { LocalProject } from "@/shell/state/layout"
import { buildHomeSessionRecords } from "./records"
const session = (id: string, directory: string, projectID: string) =>
({
@@ -1,7 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/context/layout"
import { compareSessionTime, displayName } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
import type { LocalProject } from "@/shell/state/layout"
import { compareSessionTime, displayName } from "@/shell/layout/helpers"
import { pathKey } from "@/workspaces/path-key"
export type HomeSessionRecord = {
session: SessionInfo
@@ -29,10 +29,10 @@ export function buildHomeSessionRecords(input: {
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
return { session, project, projectName: displayName(project) }
})
}
@@ -1,7 +1,7 @@
import type { HomeScrollController } from "./home-scroll-controller"
import type { HomeSessionSearchController } from "./home-session-search-controller"
import type { HomeSessionsController } from "./home-sessions-controller"
import { HomeSessionsView } from "./home-sessions-view"
import type { HomeScrollController } from "../scroll"
import type { HomeSessionSearchController } from "./search"
import type { HomeSessionsController } from "./controller"
import { HomeSessionsView } from "./view"
export function HomeSessions(props: {
sessions: HomeSessionsController
@@ -1,13 +1,13 @@
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers"
import { sessionLabel } from "@/utils/session-title"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { serverName } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { sessionLabel } from "@/session/title"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -6,18 +6,18 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionLabel } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionLabel } from "@/session/title"
import { shouldOpenSessionInBackground } from "./open"
import {
HomeSessionStatusController,
homeSessionSearchKey,
type HomeSessionGroup,
type HomeSessionRecord,
type OpenSessionOptions,
} from "./home-sessions-controller"
} from "./controller"
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
@@ -1,59 +0,0 @@
import { expect, test } from "bun:test"
import type { ProviderListResponse } from "@/types"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): ProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
})
test("selects the ready catalog for an explicit directory", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: true, providers: directory },
}),
).toBe(directory)
})
test("returns an empty catalog while an explicit directory is unresolved", () => {
expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
}),
).toEqual({ all: new Map(), connected: [], default: {} })
})
test("uses the route catalog when it is ready", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: true, providers: directory },
global: catalog("global"),
}),
).toBe(directory)
})
test("falls back to the global catalog for route consumers", () => {
const global = catalog("global")
expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
global,
}),
).toBe(global)
})
@@ -1,27 +0,0 @@
import type { ProviderListResponse } from "@/types"
export const emptyProviderCatalog: ProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
providers: ProviderListResponse
}
type ProviderCatalogInput =
| {
explicit: true
directory?: string
catalog?: DirectoryCatalog
}
| {
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: ProviderListResponse
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
if (input.directory && input.catalog?.ready) return input.catalog.providers
if (input.explicit) return emptyProviderCatalog
return input.global
}
+1 -1
View File
@@ -306,7 +306,7 @@
}
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
[data-slot="manage-models-scroll"] .settings-v2-panel {
[data-slot="manage-models-scroll"] .settings-panel {
scroll-timeline: --manage-models-scroll y;
}
+2 -28
View File
@@ -1,29 +1,3 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk"
export { useServers as useServers } from "./context/servers"
export { useSettings } from "./context/settings"
export { useTabs } from "./context/tabs"
export { useProviders } from "./hooks/use-providers"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
export { useWslServers } from "./wsl/context"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { type UpdaterPlatform, type UpdaterState } from "./updater"
export {
type WslDistroProbe,
type WslInstalledDistro,
type WslJob,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslRuntimeCheck,
type WslServerConfig,
type WslServerItem,
type WslServerRuntime,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
} from "./wsl/types"
export { ServerConnection } from "./context/servers"
export { createDraftStore, type DraftStore } from "./utils/draft-store"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection } from "./runtime/server/registry"
@@ -1,7 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSettingsCommand } from "@/components/settings-dialog"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettingsCommand } from "@/settings/command"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
export function useNewSessionCommands(input: {
restoreFocus: () => void
@@ -21,7 +21,7 @@ export function useNewSessionCommands(input: {
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogCommandPalette } = await import("@/components/dialog-command-palette")
const { DialogCommandPalette } = await import("@/shell/commands/dialog")
void dialog.show(() => <DialogCommandPalette />)
},
},
@@ -3,20 +3,17 @@ import { getDirectory } from "@opencode-ai/util/path"
import { startTransition } from "solid-js"
import type { NewSessionComposerAdapter } from "@/composer/adapter"
import { useComposerState } from "@/composer/persistence"
import {
createComposerControls,
createComposerModelSelection,
createComposerProjectControls,
} from "@/composer/selection"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { useData, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { useWorkspaceLocation } from "@/context/location"
import { createComposerControls, createComposerModelSelection } from "@/composer/selection"
import { createComposerProjectControls } from "./project/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/utils/toast"
import { showToast } from "@/shell/notifications/toast"
export function createNewSessionComposerAdapter(props: {
draftID: string
@@ -0,0 +1,66 @@
import { createMemo } from "solid-js"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerSDK } from "@/runtime/server/client"
import { serverName, ServerConnection, useServers } from "@/runtime/server/registry"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useTabs } from "@/shell/tabs/tabs"
import type { PromptProjectControls } from "./selector"
export function createComposerProjectControls(props: { draftId: string }) {
const servers = useServers()
const serverSDK = useServerSDK()
const location = useWorkspaceLocation()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const projectServer = () => serverSDK.server
const projectServerCtx = useServerCtx(projectServer)
const projects = createMemo(() => {
if (servers.list.length <= 1) return projectServerCtx().projects.list()
return servers.list.flatMap((connection) => {
const server = { key: ServerConnection.key(connection), name: serverName(connection) }
return global
.ensureServerCtx(connection)
.projects.list()
.map((project) => ({ ...project, server }))
})
})
const selectProject = (worktree: string, serverKey?: string) => {
const connection = serverKey
? servers.list.find((connection) => ServerConnection.key(connection) === serverKey)
: projectServer()
if (!connection) return
const target = global.ensureServerCtx(connection)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(props.draftId, {
server: ServerConnection.key(connection),
directory: worktree,
worktree: undefined,
})
}
const addProject = (title: string, serverKey?: string) => {
const connection = serverKey
? servers.list.find((connection) => ServerConnection.key(connection) === serverKey)
: projectServer()
if (!connection) return
pickDirectory({
server: connection,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory, serverKey)
},
})
}
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: location().directory,
server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
}))
}
@@ -12,12 +12,12 @@ import { createStore } from "solid-js/store"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { pathKey } from "@/workspaces/path-key"
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
export type PromptProject = {
name?: string
@@ -419,9 +419,7 @@ export function PromptProjectSelector(props: {
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
>
<Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">
{props.controller.labels.add()}
</span>
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
@@ -569,9 +567,7 @@ function ProjectAction(props: {
onSelect={() => props.onSelect(props.server)}
>
<Icon name="plus" size="small" />
<span class="min-w-0 truncate leading-5">
{props.controller.labels.add()}
</span>
<span class="min-w-0 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.Item>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { Navigate, useSearchParams } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { CommentsProvider } from "@/composer/comments"
import { FileProvider } from "@/workspaces/files/model"
import { useGlobal } from "@/runtime/server/runtime"
import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider, useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
export function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={tabs.ready() && <Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
)
}
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ResolvedDraftContent draft={props.draft} />
</ServerProvider>
)}
</Show>
</Show>
)
}
function ResolvedDraftContent(props: { draft: DraftTab }) {
const server = useServer()
const tabs = useTabs()
return (
<Show
when={!server.health?.incompatible}
fallback={
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<IncompatibleServerPanel
onClose={() => {
const index = tabs.store.findIndex((tab) => tab.type === "draft" && tab.draftID === props.draft.draftID)
if (index !== -1) tabs.closeTab(index)
}}
/>
</SessionPanelFrame>
</SessionRouteFrame>
}
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</Show>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because Composer uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<ComposerPersistenceProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</ComposerPersistenceProvider>
</FileProvider>
)
}
@@ -1,16 +1,16 @@
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useSettings } from "@/context/settings"
import { useTabs, type DraftTab } from "@/context/tabs"
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./new-session/composer-adapter"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
import { useNewSessionCommands } from "./new-session/use-new-session-commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
import { NewSessionStatus, NewSessionView } from "./view"
import { createNewSessionWorkspaceController } from "./workspace/controller"
import { useNewSessionCommands } from "./commands"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
@@ -8,19 +8,19 @@ import { Portal } from "solid-js/web"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import type { ComposerModel } from "@/composer/model"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
import {
PromptProjectAddButton,
PromptProjectSelector,
type PromptProjectController,
} from "@/components/prompt-project-selector"
import { StatusPopover } from "@/components/status-popover"
import { useLanguage } from "@/context/language"
import { useWorkspaceLocation } from "@/context/location"
import { useProviders } from "@/hooks/use-providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/new-session/new-session-layout"
import { Persist, persisted } from "@/utils/persist"
import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { NewSessionWorkspaceController } from "./workspace/controller"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -127,7 +127,7 @@ function ProviderTip() {
element: () => ref() ?? null,
})
const openProviders = () => {
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
@@ -4,7 +4,7 @@ import {
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./new-session-workspace-controller"
} from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -44,15 +44,15 @@ describe("new session workspace selection", () => {
expect(resolveNewSessionBranch({ worktree: "main", directory: "/project/feature", worktreeBranch: branch })).toBe(
"feature",
)
expect(
resolveNewSessionBranch({ worktree: "create", directory: "/project/feature", worktreeBranch: branch }),
).toBe("feature")
expect(resolveNewSessionBranch({ worktree: "create", directory: "/project/feature", worktreeBranch: branch })).toBe(
"feature",
)
expect(
resolveNewSessionBranch({ worktree: "/project/feature", directory: "/project", worktreeBranch: branch }),
).toBe("feature")
expect(resolveNewSessionBranch({ worktree: "/missing", directory: "/project/feature", worktreeBranch: branch })).toBe(
undefined,
)
expect(
resolveNewSessionBranch({ worktree: "/missing", directory: "/project/feature", worktreeBranch: branch }),
).toBe(undefined)
})
test("uses location VCS state when the project inventory is stale", () => {
@@ -1,16 +1,16 @@
import { createEffect, createMemo } from "solid-js"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { useSettings } from "@/context/settings"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
} from "@/utils/workspace"
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: {
enabled: boolean
@@ -3,8 +3,8 @@ import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace"
import { useLanguage } from "@/runtime/i18n/language"
import { sameDirectory } from "@/workspaces/paths"
export function PromptWorkspaceSelector(props: {
value: string
-64
View File
@@ -1,64 +0,0 @@
import { Navigate, useSearchParams } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { useGlobal } from "@/context/global"
import { LocationProvider } from "@/context/location"
import { ModelsProvider } from "@/context/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { useTabs, type DraftTab } from "@/context/tabs"
import { SessionUIProvider } from "@/pages/directory-layout"
import NewSession from "@/pages/new-session"
export function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={tabs.ready() && <Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
)
}
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ServerProvider>
)}
</Show>
</Show>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because Composer uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<ComposerPersistenceProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</ComposerPersistenceProvider>
</FileProvider>
)
}
@@ -1,50 +0,0 @@
export const deepLinkEvent = "opencode:deep-link"
const parseUrl = (input: string) => {
if (!input.startsWith("opencode://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
try {
return new URL(input)
} catch {
return
}
}
export const parseDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory")
if (!directory) return
return directory
}
export const parseNewSessionDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "new-session") return
const directory = url.searchParams.get("directory")
if (!directory) return
const prompt = url.searchParams.get("prompt") || undefined
if (!prompt) return { directory }
return { directory, prompt }
}
export const collectOpenProjectDeepLinks = (urls: string[]) =>
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
export const collectNewSessionDeepLinks = (urls: string[]) =>
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
type OpenCodeWindow = Window & {
__OPENCODE__?: {
deepLinks?: string[]
}
}
export const drainPendingDeepLinks = (target: OpenCodeWindow) => {
const pending = target.__OPENCODE__?.deepLinks ?? []
if (pending.length === 0) return []
if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = []
return pending
}
@@ -1,5 +1,5 @@
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, type Accessor } from "solid-js"
export function useIntegrations(directory: Accessor<string | undefined>) {
@@ -1,13 +1,14 @@
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProviderList } from "@/context/global-sync/utils"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog"
import { useIntegrations } from "./use-integrations"
import { popularProviders } from "./provider-order"
import type { ProviderListResponse } from "@/runtime/server/types"
import { useIntegrations } from "./integrations"
import { popularProviders } from "./order"
export { popularProviders } from "./provider-order"
export { popularProviders } from "./order"
const emptyProviderCatalog: ProviderListResponse = { all: new Map(), connected: [], default: {} }
const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
@@ -1,7 +1,7 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
@@ -2,9 +2,9 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { mockProviderAuth } from "@/context/server-sync"
import { mockProviderAuth } from "@/runtime/server/sync"
import { onCleanup, onMount } from "solid-js"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogConnectProvider, useProviderConnectController } from "./dialog"
function ConnectProviderDialogStory() {
const dialog = useDialog()
@@ -48,7 +48,7 @@ export default {
id: "app-dialog-connect-provider",
}
export const V2 = {
export const Picker = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<ConnectProviderDialogStory />
@@ -57,17 +57,17 @@ export const V2 = {
}
export const ApiKey = {
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
render: renderConnection("openrouter", [{ type: "key", label: "API key" }]),
}
export const OpenCodeZen = {
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
render: renderConnection("opencode", [{ type: "key", label: "API key" }]),
}
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "api", label: "API key" },
{ type: "key", label: "API key" },
]),
}
@@ -7,17 +7,17 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog"
import { TextInput } from "@opencode-ai/ui/text-input"
import { showToast } from "@/utils/toast"
import { showToast } from "@/shell/notifications/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useLanguage } from "@/runtime/i18n/language"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -1,11 +1,11 @@
import { Component, createMemo, Show } from "solid-js"
import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
const statusLabels = {
connected: "mcp.status.connected",
@@ -1,10 +1,10 @@
import { useMutation } from "@tanstack/solid-query"
import type { Accessor } from "solid-js"
import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { showToast } from "@/utils/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
const data = useData()
@@ -1,5 +1,5 @@
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
@@ -1,45 +1,17 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { showToast } from "@/shell/notifications/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
type Props = {
onBack: () => void
}
export function DialogCustomProvider(props: Props) {
const language = useLanguage()
return (
<Dialog class="h-full">
<DialogHeader>
<DialogTitle>
<IconButton
tabIndex={-1}
icon={<Icon name="arrow-left" />}
variant="ghost"
onClick={props.onBack}
aria-label={language.t("common.goBack")}
/>
</DialogTitle>
</DialogHeader>
<DialogBody>
<CustomProviderForm />
</DialogBody>
</Dialog>
)
}
import { ExternalLink } from "@/runtime/platform/external-link"
import { useData } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./form"
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { validateCustomProvider } from "./dialog-custom-provider-form"
import { validateCustomProvider } from "./form"
const t = (key: string) => key
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { hasCustomAgent, resolveAgent } from "./agent"
describe("hasCustomAgent", () => {
test("detects explicitly custom agents", () => {
@@ -7,15 +7,15 @@ import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/providers/models/selection"
import { popularProviders } from "@/providers/catalog/providers"
import { useLanguage } from "@/runtime/i18n/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { decode64 } from "@/utils/base64"
import { SettingsListV2 } from "./settings-v2/parts/list"
import { SettingsRowV2 } from "./settings-v2/parts/row"
import "./settings-v2/settings-v2.css"
import { DialogConnectProvider } from "@/providers/connect/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
@@ -57,7 +57,7 @@ export const DialogManageModels: Component = () => {
})
return (
<Dialog size="large" variant="settings" class="settings-v2-manage-models-dialog">
<Dialog size="large" variant="settings" class="settings-manage-models-dialog">
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
<DialogTitleGroup
title={language.t("dialog.model.manage")}
@@ -89,7 +89,7 @@ export const DialogManageModels: Component = () => {
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
aria-label={language.t("common.clear")}
@@ -98,11 +98,11 @@ export const DialogManageModels: Component = () => {
</div>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
<div class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<Show
when={!list.grouped.loading}
fallback={
<div class="settings-v2-models-status">
<div class="settings-models-status">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
@@ -111,21 +111,21 @@ export const DialogManageModels: Component = () => {
<Show
when={list.flat().length > 0}
fallback={
<div class="settings-v2-models-status">
<div class="settings-models-status">
<span>{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="settings-v2-models-status-filter">&quot;{list.filter()}&quot;</span>
<span class="settings-models-status-filter">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header justify-between">
<div class="settings-section" data-component="settings-models-provider">
<div class="settings-models-group-header justify-between">
<div class="flex min-w-0 items-center gap-2">
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
<h3 class="settings-section-title">{group.items[0].provider.name}</h3>
</div>
<div>
<Switch
@@ -138,10 +138,10 @@ export const DialogManageModels: Component = () => {
</Switch>
</div>
</div>
<SettingsListV2>
<SettingsList>
<For each={group.items}>
{(item) => (
<SettingsRowV2 title={item.name} description="">
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
@@ -151,10 +151,10 @@ export const DialogManageModels: Component = () => {
{item.name}
</Switch>
</div>
</SettingsRowV2>
</SettingsRow>
)}
</For>
</SettingsListV2>
</SettingsList>
</div>
)}
</For>
@@ -3,8 +3,8 @@ import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
export type ModelKey = { providerID: string; modelID: string }
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { matchesModelSearch } from "./dialog-select-model-search"
import { matchesModelSearch } from "./search"
describe("matchesModelSearch", () => {
test("matches model names across separators", () => {
@@ -1,9 +1,9 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useLocal } from "@/providers/models/selection"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode-ai/ui/button"
import { Badge } from "@opencode-ai/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
@@ -13,13 +13,13 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Menu } from "@opencode-ai/ui/menu"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { decode64 } from "@/runtime/persistence/base64"
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
import { matchesModelSearch } from "./search"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -111,7 +111,7 @@ const ModelList: Component<{
)
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Popover.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
export function ModelSelectorPopover(props: {
provider?: string
@@ -134,7 +134,7 @@ export function ModelSelectorPopover(props: {
current={controller.current()}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void import("./manage").then((module) => {
void dialog.show(() => <module.DialogManageModels />)
})
}}
@@ -419,13 +419,13 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const directory = () => decode64(local.slug())
const provider = () => {
void import("./dialog-connect-provider").then((x) => {
void import("@/providers/connect/dialog").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
})
}
const manage = () => {
void import("./dialog-manage-models").then((x) => {
void import("./manage").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
@@ -3,17 +3,17 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useWorkspaceLocation } from "./location"
import { useData } from "./server"
import { normalizeAgentList } from "./global-sync/utils"
import { useServerSDK } from "./server-sdk"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
@@ -1,5 +1,5 @@
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useLanguage } from "@/runtime/i18n/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
type InputMap = Record<InputKey, boolean>

Some files were not shown because too many files have changed in this diff Show More