chore: merge dev into event v2 branch

This commit is contained in:
Dax Raad
2026-05-26 12:13:39 -04:00
66 changed files with 4353 additions and 1042 deletions
+27 -11
View File
@@ -192,6 +192,26 @@
"cloudflare": "5.2.0",
},
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "0.0.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"vite": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"wrangler": "4.50.0",
},
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.10",
@@ -207,7 +227,7 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
@@ -434,7 +454,7 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
@@ -911,7 +931,7 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
@@ -1647,6 +1667,8 @@
"@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"],
"@opencode-ai/console-support": ["@opencode-ai/console-support@workspace:packages/console/support"],
"@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"],
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
@@ -5225,12 +5247,10 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6169,10 +6189,6 @@
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+2 -44
View File
@@ -7,50 +7,8 @@ export const domain = (() => {
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// Dev owns the shared AWS lake/stats infra for all non-production stages.
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
const githubActionsDeployRole = (() => {
if ($app.stage !== "dev" && $app.stage !== "production") return
const provider = new aws.iam.OpenIdConnectProvider("GithubActionsOidcProvider", {
url: "https://token.actions.githubusercontent.com",
clientIdLists: ["sts.amazonaws.com"],
})
const role = new aws.iam.Role("GithubActionsDeployRole", {
name: `opencode-${$app.stage}-github-actions-deploy`,
maxSessionDuration: 3600,
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: ["sts:AssumeRoleWithWebIdentity"],
principals: [{ type: "Federated", identifiers: [provider.arn] }],
conditions: [
{
test: "StringEquals",
variable: "token.actions.githubusercontent.com:aud",
values: ["sts.amazonaws.com"],
},
{
test: "StringEquals",
variable: "token.actions.githubusercontent.com:sub",
values: [`repo:anomalyco/opencode:environment:${$app.stage}`],
},
],
},
],
}).json,
})
new aws.iam.RolePolicyAttachment("GithubActionsDeployRoleAdmin", {
role: role.name,
policyArn: "arn:aws:iam::aws:policy/AdministratorAccess",
})
return role
})()
export const githubActionsDeployRoleArn = githubActionsDeployRole?.arn
// Temporarily omit AWS infra so SST removes the lake/stats resources.
export const deployAws = false
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-6s5msV+dYMHHt9Gc/CqvCrUj8K7ELjxoAe6ejHSJo4I=",
"aarch64-linux": "sha256-SP94UPy5LePd7ZdC3eENIXiozc67blpg1SN9Ug5yiv8=",
"aarch64-darwin": "sha256-jv3lffiAQ5kxDAbXNavsHD+tjjdgT0dT0JxN0bWMYTE=",
"x86_64-darwin": "sha256-aYNtcarg516ZmqaO62mnUZYiSyWt8rJjUHQslhrhGHM="
"x86_64-linux": "sha256-p8EPw6YDdAaToGibLAljdtLuuSIqQ3mVJecYOwUWqY0=",
"aarch64-linux": "sha256-G/xOunyVzqJucokje713GkysXgixJmce6jlevRht7Sc=",
"aarch64-darwin": "sha256-zMeGIyWXUb0ofrUGxTluAytZMkTtlyEydIhbu62O2VM=",
"x86_64-darwin": "sha256-ef7+QlnKmtB2fGOw3EFjlyZcgj+Q8XLbNVdPQiNNvh8="
}
}
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -299,39 +300,13 @@ function readExpanded(element: Element) {
}
async function mockServer(page: Page, events: EventPayload[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event") return sse(route, events.splice(0))
if (
path === "/global/config" ||
path === "/config" ||
path === "/provider/auth" ||
path === "/mcp" ||
path === "/session/status"
)
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
return json(route, {})
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
events: () => events.splice(0),
})
}
@@ -372,24 +347,6 @@ function provider() {
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events: EventPayload[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
headers: { "access-control-allow-origin": "*" },
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
})
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
@@ -207,33 +208,12 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
}
async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path))
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, messages)
return json(route, {})
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: messages }),
})
}
@@ -282,19 +262,6 @@ function provider() {
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+9 -3
View File
@@ -18,6 +18,7 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
events?: () => unknown[]
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -43,7 +44,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
if (path === "/global/health") return json(route, { healthy: true })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -81,6 +83,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
function sse(route: Route, events?: unknown[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
})
}
Binary file not shown.
+29 -25
View File
@@ -46,6 +46,12 @@ import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
document.body.classList.remove("text-12-regular")
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
}
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -296,31 +302,29 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServersProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
</ServersProvider>
</ServerProvider>
)
}
+489 -227
View File
@@ -4,6 +4,8 @@ import {
createEffect,
on,
Component,
splitProps,
For,
Show,
onCleanup,
createMemo,
@@ -11,7 +13,10 @@ import {
createResource,
Switch,
Match,
type ComponentProps,
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -26,12 +31,14 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -44,6 +51,7 @@ import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
@@ -68,14 +76,16 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { getFilename } from "@opencode-ai/core/util/path"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
@@ -113,11 +123,9 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const navigate = useNavigate()
const queryOptions = useQueryOptions()
const sync = useSync()
@@ -125,6 +133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const files = useFile()
const prompt = usePrompt()
const layout = useLayout()
const server = useServer()
const comments = useComments()
const dialog = useDialog()
const providers = useProviders()
@@ -132,11 +141,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let projectSearchRef: HTMLInputElement | undefined
const mirror = { input: false }
const inset = 56
@@ -277,6 +288,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal",
applyingHistory: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
projectSearch: "",
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
@@ -1303,91 +1318,124 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return "Ask anything, / for commands, @ for context..."
}
const modelControl = () => (
<Show when={!providersLoading()}>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
paid: providers.paid().length > 0,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
model: local.model,
providerID: local.model.current()?.provider?.id,
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
},
}))
const newSession = () => props.variant === "new-session"
const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
const currentWorktree = createMemo(() => {
if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
return props.newSessionWorktree ?? MAIN_WORKTREE
return MAIN_WORKTREE
const projects = createMemo(() => layout.projects.list())
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
return projects().find(
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const worktreeLabel = (value: string) => {
if (value === MAIN_WORKTREE) return MAIN_WORKTREE
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
return getFilename(value)
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
projectSearch: "",
})
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
restoreFocus()
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = async () => {
const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result
if (!directory) return
selectProject(directory)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} />,
() => select(null),
)
})
}
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const projectPickerState = createMemo<ComposerPickerState>(() => ({
open: picker.projectOpen,
trigger: {
action: "prompt-project",
icon: "folder",
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"),
class: "max-w-[203px]",
style: control(),
onPress: () => setPicker("projectOpen", true),
},
search: picker.projectSearch,
searchPlaceholder: language.t("session.new.project.search"),
clearLabel: language.t("common.clear"),
items: projectResults().map((project) => ({
icon: "folder",
label: displayName(project),
selected: selectedProject()?.worktree === project.worktree,
onSelect: () => selectProject(project.worktree),
})),
action: {
icon: "plus",
label: language.t("session.new.project.add"),
onSelect: () => {
setPicker("projectOpen", false)
void addProject()
},
},
onOpenChange: (open) => {
setPicker("projectOpen", open)
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
},
onSearchInput: (value) => setPicker("projectSearch", value),
onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el),
}))
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: agentNames(),
current: local.agent.current()?.name ?? "",
style: control(),
onSelect: (value) => {
local.agent.set(value)
restoreFocus()
},
}))
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
action: "prompt-project",
icon: "folder-add-left",
label: language.t("session.new.project.new"),
class: "max-w-[160px]",
style: control(),
onPress: () => void addProject(),
}))
return (
<div class="relative size-full flex flex-col gap-0">
@@ -1409,154 +1457,145 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<Switch>
<Match when={USE_V2_INPUT}>
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative min-h-[52px]"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
editorRef?.focus()
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative min-h-[52px]"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action^="prompt-"]')) return
editorRef?.focus()
}}
>
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
</div>
</div>
</div>
</div>
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipKeybind>
<Show when={newSession()}>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="sliders" size="small" />
</div>
<Select
size="normal"
options={worktrees()}
current={currentWorktree()}
label={worktreeLabel}
onSelect={(value) => {
if (value) props.onNewSessionWorktreeChange?.(value)
restoreFocus()
}}
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-workspace" }}
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</div>
</Show>
{modelControl()}
</TooltipKeybind>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
<Show when={newSession() && !selectedProject()}>
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} />
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</DockShellForm>
</DockShellForm>
<Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState()} />
</div>
</Show>
</div>
</Match>
<Match when>
<DockShellForm
@@ -1892,3 +1931,226 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
)
}
type ComposerPickerItemState = {
icon: IconProps["name"]
label: string
selected?: boolean
onSelect: () => void
}
type ComposerPickerTriggerState = {
action: string
icon?: IconProps["name"]
label: string
class?: string
style: JSX.CSSProperties | undefined
onPress: () => void
}
type ComposerPickerState = {
open: boolean
trigger: ComposerPickerTriggerState
search: string
searchPlaceholder: string
clearLabel: string
items: ComposerPickerItemState[]
action: ComposerPickerItemState
listClass?: string
searchRef: (el: HTMLInputElement) => void
onOpenChange: (open: boolean) => void
onSearchInput: (value: string) => void
onSearchClear: () => void
}
type ComposerAgentControlState = {
title: string
keybind: string
options: string[]
current: string
style: JSX.CSSProperties | undefined
onSelect: (value: string | undefined) => void
}
type ComposerModelControlState = {
loading: boolean
paid: boolean
title: string
keybind: string
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
style: JSX.CSSProperties | undefined
onClose: () => void
onUnpaidClick: () => void
}
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
return (
<button
{...rest}
data-action={local.state.action}
type="button"
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
style={local.state.style}
onClick={() => local.state.onPress()}
>
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect}
>
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ComposerPicker(props: { state: ComposerPickerState }) {
return (
<KobaltePopover
open={props.state.open}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={props.state.onOpenChange}
>
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
<KobaltePopover.Portal>
<KobaltePopover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={props.state.searchRef}
value={props.state.search}
placeholder={props.state.searchPlaceholder}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
/>
<Show when={props.state.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.state.onSearchClear}
aria-label={props.state.clearLabel}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)
}
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Select
size="normal"
options={props.state.options}
current={props.state.current}
onSelect={props.state.onSelect}
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
)
}
function ComposerModelControl(props: { state: ComposerModelControlState }) {
return (
<Show when={!props.state.loading}>
<Show
when={props.state.paid}
fallback={
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
}
@@ -24,7 +24,9 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
const OPEN_APPS = [
"vscode",
@@ -43,6 +45,8 @@ const OPEN_APPS = [
"sublime-text",
] as const
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
@@ -153,11 +157,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -231,6 +235,14 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
@@ -311,193 +323,235 @@ export function SessionHeader() {
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
<Show
when={isDesktopV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
)
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-0">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</div>
)
}
@@ -1,76 +1,13 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const serverSync = useServerSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
const server = useServer()
const sync = useSync()
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = serverSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
const openProject = (directory: string | undefined) => {
if (!directory) return
if (directory === projectRoot()) return
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">
{props.children}
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
<Select
size="normal"
variant="ghost"
options={projects()}
current={projectRoot()}
label={getFilename}
onSelect={openProject}
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
/>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="branch" size="small" />
</div>
<Select
size="normal"
variant="ghost"
options={[branch()]}
current={branch()}
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
/>
</div>
</div>
</div>
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
@@ -444,18 +444,6 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
@@ -467,6 +455,18 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -781,7 +781,6 @@ export const SettingsGeneral: Component = () => {
</Show>
)
console.log(import.meta.env)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -803,7 +802,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<AdvancedSection />
</Show>
</div>
@@ -7,16 +7,17 @@ import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useServers } from "@/context/servers"
const pollMs = 10_000
@@ -54,40 +55,6 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
@@ -166,8 +133,162 @@ const useMcpToggleMutation = () => {
}))
}
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverServerBody() {
const servers = useServers()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
const key = ServerConnection.key(conn)
return {
key,
conn,
health: servers.health[key],
blocked: servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
queueMicrotask(() => server.setActive(key))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
servers: serverItems,
defaultKey: defaultServer.key,
ariaLabel: language.t("status.popover.ariaLabel"),
serversLabel: language.t("status.popover.tab.servers"),
defaultLabel: language.t("common.default"),
manageLabel: language.t("status.popover.action.manageServers"),
onManage: () => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={props.state.servers()}>
{(item) => {
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const servers = useServers()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
@@ -192,15 +313,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -226,7 +339,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -249,7 +362,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false
const blocked = () => servers.health[key]?.healthy === false
return (
<button
type="button"
@@ -265,11 +378,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerHealthIndicator health={servers.health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={health[key]}
status={servers.health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
+144 -7
View File
@@ -1,19 +1,24 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useServers } from "@/context/servers"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -21,7 +26,8 @@ export function StatusPopover() {
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
const serverHealthy = () => servers.health[server.key]?.healthy === true
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
return (
<Popover
@@ -43,10 +49,9 @@ export function StatusPopover() {
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
"bg-icon-critical-base":
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
/>
</div>
@@ -68,3 +73,135 @@ export function StatusPopover() {
</Popover>
)
}
export function StatusPopoverV2(props: { scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: "bottom-end" as const,
shift: -168,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
describe("titlebar session events", () => {
test("reads valid removed session tab details", () => {
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
}),
),
).toEqual({
directory: "/tmp/project",
sessionIDs: ["ses_1", "ses_2"],
})
})
test("ignores invalid removed session tab details", () => {
expect(readSessionTabsRemovedDetail(new Event(SESSION_TABS_REMOVED_EVENT))).toBeUndefined()
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: [] },
}),
),
).toBeUndefined()
})
})
@@ -0,0 +1,29 @@
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
directory: string
sessionIDs: string[]
}
export function notifySessionTabsRemoved(input: SessionTabsRemovedDetail) {
window.dispatchEvent(new CustomEvent(SESSION_TABS_REMOVED_EVENT, { detail: input }))
}
export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDetail | undefined {
if (!(event instanceof CustomEvent)) return undefined
const detail: unknown = event.detail
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
directory: detail.directory,
sessionIDs,
}
}
+102 -35
View File
@@ -23,8 +23,12 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { StatusPopover } from "./status-popover"
import { SDKProvider } from "@/context/sdk"
import { StatusPopoverV2 } from "@/components/status-popover"
import {
readSessionTabsRemovedDetail,
SESSION_TABS_REMOVED_EVENT,
type SessionTabsRemovedDetail,
} from "@/components/titlebar-session-events"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -115,7 +119,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
visible: version !== undefined,
installing: props.update?.installing() ?? false,
label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
onInstall: () => props.update?.install(),
}
})
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
update: updateState(),
statusVisible: !params.dir && settings.general.showStatus(),
statusLabel: language.t("status.popover.trigger"),
}))
const back = () => {
const next = backPath(history)
@@ -261,7 +281,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
},
removeTab: (href: string) => {
startTransition(() => {
void startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
@@ -274,11 +294,45 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
})
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
const removedCurrent =
currentIndex !== -1 &&
tabs[currentIndex]?.dir === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab) continue
if (tab.dir !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
const detail = readSessionTabsRemovedDetail(event)
if (!detail) return
tabsStoreActions.removeSessions(detail)
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
@@ -465,16 +519,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</Show>
<div class="min-w-0 flex-1" />
</div>
<Show when={currentSessionTab()?.dir} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</SDKProvider>
)}
</Show>
<TitlebarUpdatePill update={props.update} />
<TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
@@ -641,28 +686,50 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
const language = useLanguage()
const version = () => props.update?.version()
type TitlebarUpdatePillState = {
visible: boolean
installing: boolean
label: string
ariaLabel: string
title?: string
onInstall: () => void
}
type TitlebarV2RightState = {
update: TitlebarUpdatePillState
statusVisible: boolean
statusLabel: string
}
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<Show when={version() !== undefined}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
onClick={() => props.update?.install()}
disabled={props.update?.installing()}
aria-label={language.t("toast.update.action.installRestart")}
title={version() ? `Update ${version()}` : undefined}
>
Update
</button>
</Show>
<div class="flex shrink-0 items-center justify-end gap-0">
<TitlebarUpdatePill state={props.state.update} />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 scope="server" />
</Tooltip>
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
return
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
return (
<Show when={props.state.visible}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60"
onClick={props.state.onInstall}
disabled={props.state.installing}
aria-label={props.state.ariaLabel}
title={props.state.title}
>
{props.state.label}
</button>
</Show>
)
}
function TabNavItem(props: {
@@ -682,10 +749,10 @@ function TabNavItem(props: {
>
<a
href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<ProjectTabAvatar project={props.project} directory={props.directory} />
<span class="text-clip">{props.title}</span>
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@@ -727,12 +794,12 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate">{props.title}</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
+33 -18
View File
@@ -8,8 +8,8 @@ import {
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { createServerSyncContext, useServerSync } from "./server-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { createServerSyncContext } from "./server-sync"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk"
@@ -34,6 +34,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const isNotFound = (error: unknown) =>
error instanceof Error &&
typeof error.cause === "object" &&
error.cause !== null &&
(error.cause as { status?: unknown }).status === 404
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
@@ -347,6 +353,10 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
})
})
})
.catch((error) => {
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
throw error
})
.finally(() => {
setMeta(
produce((draft) => {
@@ -458,22 +468,27 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
: retry(() => client.session.get({ sessionID }))
.then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
.catch((error) => {
if (isNotFound(error) && !tracked(directory, sessionID)) return
throw error
})
const messagesReq =
cached && !opts?.force
+20 -64
View File
@@ -1,8 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -37,24 +36,30 @@ export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const servers = [
...input.stored.map((value) =>
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...(input.props ?? []),
]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
: "http" in value
? value
: { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
const existing = deduped.get(key)
if (existing)
deduped.set(key, {
...existing,
...conn,
http: { ...existing.http, ...conn.http },
})
else deduped.set(key, conn)
}
return [...deduped.values()]
@@ -122,13 +127,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
@@ -146,36 +145,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({
active: props.defaultServer,
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
function startHealthPolling(conn: ServerConnection.Any) {
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(conn)
.then((next) => {
if (!alive) return
setState("healthy", next)
})
.finally(() => {
busy = false
})
}
run()
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
return () => {
alive = false
clearInterval(interval)
}
}
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
}
@@ -209,20 +180,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
createEffect(() => {
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
@@ -235,7 +192,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return {
ready: isReady,
healthy,
isLocal,
get key() {
return state.active
+20
View File
@@ -0,0 +1,20 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
name: "Servers",
init: () => {
const server = useServer()
const health = useServerHealth(
() => server.list,
() => true,
)
return {
list: () => server.list,
health,
}
},
})
+6
View File
@@ -32,6 +32,7 @@ export interface Settings {
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
}
updates: {
startup: boolean
@@ -117,6 +118,7 @@ const defaultSettings: Settings = {
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
@@ -236,6 +238,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
+8 -1
View File
@@ -221,6 +221,7 @@ export const dict = {
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.cancel": "Cancel",
"common.clear": "Clear",
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
@@ -531,6 +532,7 @@ export const dict = {
"home.project.add": "Add project",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.empty": "No sessions found",
"home.sessions.empty.description": "Start a new session for this project",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
@@ -584,6 +586,9 @@ export const dict = {
"session.revertDock.restore": "Restore message",
"session.new.title": "Build anything",
"session.new.project.new": "New project",
"session.new.project.search": "Search projects",
"session.new.project.add": "Add project",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
@@ -764,7 +769,7 @@ export const dict = {
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
@@ -773,6 +778,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
+1 -2
View File
@@ -759,8 +759,7 @@ export const dict = {
"settings.general.row.followup.option.queue": "Черга",
"settings.general.row.followup.option.steer": "Керування",
"settings.general.row.showFileTree.title": "Дерево файлів",
"settings.general.row.showFileTree.description":
"Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showNavigation.title": "Елементи навігації",
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
"settings.general.row.showSearch.title": "Палітра команд",
+7
View File
@@ -8,6 +8,13 @@
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/assets/Inter.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@layer components {
@keyframes session-progress-whip {
0% {
+337 -74
View File
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -9,6 +9,7 @@ import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { MenuV2 } from "@opencode-ai/ui/v2/components/menu-v2.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -18,23 +19,24 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/core/util/path"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useServers } from "@/context/servers"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-8 gap-1.5 px-3 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-7 gap-2 px-1.5 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
type HomeSessionRecord = {
@@ -62,16 +64,18 @@ function HomeDesign() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const notification = useNotification()
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return []
return [project.worktree, ...(project.sandboxes ?? [])]
return directories(project)
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
@@ -134,6 +138,26 @@ function HomeDesign() {
navigate(`/${base64Encode(project.worktree)}/session`)
}
function openProjectNewSession(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
const showEditProjectDialog = (project: LocalProject) => {
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
const unseenCount = (project: LocalProject) =>
directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
const clearNotifications = (project: LocalProject) =>
directories(project)
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
@@ -175,10 +199,17 @@ function HomeDesign() {
return (
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
selected={selectedProject()?.worktree}
selectedProject={state.project}
selectProject={selectProject}
openNewSession={openProjectNewSession}
chooseProject={() => void chooseProject()}
editProject={showEditProjectDialog}
closeProject={(directory) => {
layout.projects.close(directory)
if (state.project === directory) setState("project", undefined)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
@@ -188,55 +219,84 @@ function HomeDesign() {
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
onInput={(value) => setState("search", value)}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<Show
when={selectedProject()}
fallback={
<HomeEmptyState
icon="folder-add-left"
title={language.t("home.empty.title")}
description={language.t("home.empty.description")}
action={language.t("home.project.add")}
onAction={() => void chooseProject()}
/>
}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
onInput={(value) => setState("search", value)}
clearLabel={language.t("common.clear")}
onClear={() => setState("search", "")}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
</div>
}
when={!sessionLoad.isLoading}
fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
<Show
when={groups().length > 0}
fallback={
<HomeEmptyState
icon="edit"
title={language.t("home.sessions.empty")}
description={language.t("home.sessions.empty.description")}
action={language.t("command.session.new")}
onAction={openNewSession}
/>
}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
</div>
</div>
</div>
)}
</For>
)}
</For>
</Show>
</Show>
</Show>
</div>
</div>
</div>
</Show>
</section>
</div>
)
}
function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectedProject?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
const servers = useServers()
const layout = useLayout()
const projects = createMemo(() => layout.projects.list())
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
@@ -251,38 +311,77 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.project.add")}
/>
</div>
<div class="mt-4 flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<For each={props.projects}>
{(project) => (
<button
type="button"
data-component="home-project-row"
class={HOME_PROJECT_NAV_ROW}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
data-selected={props.selected === project.worktree ? "" : undefined}
aria-current={props.selected === project.worktree ? "page" : undefined}
onClick={() => props.selectProject(project.worktree)}
>
<HomeProjectAvatar project={project} />
<span>{displayName(project)}</span>
</button>
)}
</For>
</Show>
</div>
<For
each={servers.list()}
fallback={
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
}
>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
return (
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
)
}}
</For>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
@@ -305,6 +404,80 @@ function HomeProjectColumn(props: {
)
}
function HomeProjectRow(props: {
project: LocalProject
selected: boolean
unseenCount: number
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
return (
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
onClick={() => props.selectProject(props.project.worktree)}
>
<HomeProjectAvatar project={props.project} />
<span>{name()}</span>
</button>
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={(event) => {
event.stopPropagation()
props.openNewSession(props.project.worktree)
}}
/>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="menu" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<Icon name="new-session" size="small" />
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<Icon name="edit" size="small" />
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<Icon name="close" size="small" />
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
}
function HomeProjectAvatar(props: { project: LocalProject }) {
const name = createMemo(() => displayName(props.project))
return (
@@ -319,7 +492,13 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
function HomeSessionSearch(props: {
value: string
placeholder: string
clearLabel: string
onInput: (value: string) => void
onClear: () => void
}) {
return (
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
<IconV2 name="magnifying-glass" size="small" />
@@ -330,10 +509,46 @@ function HomeSessionSearch(props: { value: string; placeholder: string; onInput:
aria-label={props.placeholder}
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
<Show when={props.value.trim()}>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
aria-label={props.clearLabel}
onClick={(event) => {
event.preventDefault()
props.onClear()
}}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</label>
)
}
function HomeEmptyState(props: {
icon: Parameters<typeof IconV2>[0]["name"]
title: string
description: string
action: string
onAction: () => void
}) {
return (
<div class="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
<div class="flex size-10 items-center justify-center rounded-[10px] bg-v2-background-bg-deep text-v2-icon-icon-muted shadow-[var(--v2-elevation-raised)]">
<IconV2 name={props.icon} />
</div>
<div class="flex max-w-[320px] flex-col gap-1">
<div class="text-v2-text-text-base [font-weight:530]">{props.title}</div>
<div class="text-v2-text-text-muted [font-weight:440]">{props.description}</div>
</div>
<ButtonV2 variant="neutral" size="normal" icon={props.icon} onClick={props.onAction}>
{props.action}
</ButtonV2>
</div>
)
}
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (
@@ -464,6 +679,7 @@ function LegacyHome() {
const platform = usePlatform()
const dialog = useDialog()
const navigate = useNavigate()
const servers = useServers()
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
@@ -475,7 +691,7 @@ function LegacyHome() {
})
const serverDotClass = createMemo(() => {
const healthy = server.healthy()
const healthy = servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
@@ -581,3 +797,50 @@ function LegacyHome() {
</div>
)
}
function ProjectList(props: {
projects: LocalProject[]
selectedProject?: string
onSelectedProjectChange?(project: string): void
onChooseProject?(): void
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}) {
return (
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={() => props.onChooseProject?.()}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<div class="flex flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
selected={props.selectedProject === project.worktree}
unseenCount={props.unseenCount(project)}
selectProject={(directory) => props.onSelectedProjectChange?.(directory)}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
</Show>
)
}
+1 -4
View File
@@ -1663,7 +1663,6 @@ export default function Page() {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
@@ -1800,9 +1799,7 @@ export default function Page() {
</Match>
<Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView worktree={newSessionWorktree()}>
{composerRegion("inline")}
</NewSessionDesignView>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
@@ -25,7 +25,6 @@ export function SessionComposerRegion(props: {
placement?: "dock" | "inline"
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
@@ -265,7 +264,6 @@ export function SessionComposerRegion(props: {
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
@@ -65,6 +65,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { makeTimer } from "@solid-primitives/timer"
@@ -861,7 +862,9 @@ export function MessageTimeline(props: {
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync.session.evict(sessionID)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
})
.catch((err) => {
showToast({
@@ -892,42 +895,46 @@ export function MessageTimeline(props: {
if (!result) return false
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of sync.data.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
for (const id of removed) {
sync.session.evict(id)
}
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] })
return true
}
@@ -28,6 +28,8 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@@ -58,12 +60,8 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
@@ -20,6 +20,8 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
@@ -70,10 +72,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
const id = params.id
+39 -1
View File
@@ -1,6 +1,8 @@
import { usePlatform } from "@/context/platform"
import type { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string }
@@ -92,6 +94,8 @@ export async function checkServerHealth(
return attempt(0).finally(() => timeout?.clear?.())
}
const pollMs = 10_000
export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
@@ -111,3 +115,37 @@ export function useCheckServerHealth() {
return promise
}
}
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@opencode-ai/console-support",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "sst shell --stage production -- vite dev"
},
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"vite": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"wrangler": "4.50.0"
},
"engines": {
"node": ">=22"
}
}
+133
View File
@@ -0,0 +1,133 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: #0d0d0d;
color: #e6e6e6;
}
main[data-page="support"] {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
main[data-page="support"] h1 {
margin: 0 0 1rem;
font-size: 1.25rem;
font-weight: 500;
}
form[data-component="lookup"] {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
align-items: center;
flex-wrap: wrap;
}
form[data-component="lookup"] input[type="text"] {
flex: 1;
min-width: 280px;
padding: 0.6rem 0.75rem;
border: 1px solid #2a2a2a;
background: #161616;
color: #e6e6e6;
border-radius: 4px;
font: inherit;
}
form[data-component="lookup"] label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #b0b0b0;
}
form[data-component="lookup"] button {
padding: 0.6rem 1rem;
border: 1px solid #3a3a3a;
background: #1f1f1f;
color: #e6e6e6;
border-radius: 4px;
font: inherit;
cursor: pointer;
}
form[data-component="lookup"] button:hover {
background: #2a2a2a;
}
form[data-component="lookup"] button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
[data-component="error"] {
color: #ff6b6b;
background: #2a1414;
border: 1px solid #4a1f1f;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
[data-component="section"] {
margin-bottom: 2rem;
}
[data-component="section"] h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 500;
color: #b0b0b0;
border-bottom: 1px solid #2a2a2a;
padding-bottom: 0.4rem;
}
[data-component="section"] h3 {
margin: 1.75rem 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #888;
}
[data-component="section"] h3:first-of-type {
margin-top: 0;
}
[data-component="section"] table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
[data-component="section"] th,
[data-component="section"] td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #1f1f1f;
vertical-align: top;
white-space: nowrap;
}
[data-component="section"] th {
color: #888;
font-weight: 500;
background: #141414;
}
[data-component="section"] td a {
color: #6ea8fe;
}
[data-component="section"] [data-empty] {
color: #666;
font-style: italic;
padding: 0.4rem 0;
}
+21
View File
@@ -0,0 +1,21 @@
import { MetaProvider, Title } from "@solidjs/meta"
import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js"
import "./app.css"
export default function App() {
return (
<Router
explicitLinks={true}
root={(props) => (
<MetaProvider>
<Title>opencode support</Title>
<Suspense>{props.children}</Suspense>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
)
}
@@ -0,0 +1,119 @@
import { For, Show } from "solid-js"
import type { LookupResult, WorkspaceSection } from "~/lib/lookup"
export function Result(props: { data: LookupResult }) {
return (
<>
<Show when={props.data.auth}>
{(auth) => (
<section data-component="section">
<h2>Auth</h2>
<DataTable rows={auth()} />
</section>
)}
</Show>
<Show when={props.data.accountWorkspaces}>
{(workspaces) => (
<section data-component="section">
<h2>Workspaces</h2>
<DataTable rows={workspaces()} />
</section>
)}
</Show>
<For each={props.data.workspaces}>{(ws) => <WorkspaceView section={ws} />}</For>
</>
)
}
function WorkspaceView(props: { section: WorkspaceSection }) {
return (
<section data-component="section" id={`workspace-${props.section.workspaceID}`}>
<h2>{props.section.title}</h2>
<h3>Users</h3>
<DataTable rows={props.section.users} />
<h3>Billing</h3>
<DataTable rows={props.section.billing ? [props.section.billing] : []} />
<h3>GO</h3>
<DataTable rows={props.section.go} />
<h3>Payments</h3>
<DataTable rows={props.section.payments} />
<h3>28-Day Usage</h3>
<DataTable rows={props.section.usage} />
<h3>Disabled Models</h3>
<DataTable rows={props.section.disabledModels} />
</section>
)
}
function DataTable(props: { rows: Record<string, unknown>[] }) {
const columns = () => {
const cols = new Set<string>()
for (const row of props.rows) {
for (const key of Object.keys(row)) cols.add(key)
}
return [...cols]
}
return (
<Show when={props.rows.length > 0} fallback={<div data-empty>(no data)</div>}>
<table>
<thead>
<tr>
<For each={columns()}>{(col) => <th>{col}</th>}</For>
</tr>
</thead>
<tbody>
<For each={props.rows}>
{(row) => (
<tr>
<For each={columns()}>{(col) => <td>{renderCell(row[col])}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
</Show>
)
}
function renderCell(value: unknown) {
if (value === null || value === undefined) return ""
if (typeof value === "string" && value.startsWith("https://")) {
return (
<a href={value} target="_blank" rel="noopener noreferrer">
{value}
</a>
)
}
if (isLinkCell(value)) {
const external = value.__link.startsWith("http")
return (
<a
href={value.__link}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
{value.label}
</a>
)
}
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
function isLinkCell(value: unknown): value is { __link: string; label: string } {
return (
typeof value === "object" &&
value !== null &&
"__link" in value &&
typeof (value as { __link: unknown }).__link === "string"
)
}
@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client"
mount(() => <StartClient />, document.getElementById("app")!)
@@ -0,0 +1,26 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
export default createHandler(
() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
),
{
mode: "async",
},
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
+479
View File
@@ -0,0 +1,479 @@
"use server"
import { Database, and, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import {
BillingTable,
PaymentTable,
SubscriptionTable,
BlackPlans,
UsageTable,
LiteTable,
} from "@opencode-ai/console-core/schema/billing.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
export type LookupResult = {
identifier: string
auth?: Record<string, unknown>[]
accountWorkspaces?: Record<string, unknown>[]
workspaces: WorkspaceSection[]
}
export type WorkspaceSection = {
workspaceID: string
title: string
users: Record<string, unknown>[]
billing: Record<string, unknown> | null
go: Record<string, unknown>[]
payments: Record<string, unknown>[]
usage: Record<string, unknown>[]
disabledModels: Record<string, unknown>[]
}
export async function lookup(identifier: string): Promise<LookupResult> {
if (!identifier) throw new Error("Identifier is required")
if (identifier.startsWith("wrk_")) {
const workspace = await loadWorkspace(identifier)
return { identifier, workspaces: [workspace] }
}
if (identifier.startsWith("key_")) {
const key = await Database.use((tx) =>
tx
.select()
.from(KeyTable)
.where(eq(KeyTable.id, identifier))
.then((rows) => rows[0]),
)
if (!key) throw new Error("API key not found")
const workspace = await loadWorkspace(key.workspaceID)
return { identifier, workspaces: [workspace] }
}
if (identifier.startsWith("sk-")) {
const key = await Database.use((tx) =>
tx
.select()
.from(KeyTable)
.where(eq(KeyTable.key, identifier))
.then((rows) => rows[0]),
)
if (!key) throw new Error("API key not found")
const workspace = await loadWorkspace(key.workspaceID)
return { identifier, workspaces: [workspace] }
}
// Treat as email
const authData = await Database.use((tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)))
if (authData.length === 0) throw new Error("Email not found")
const accountID = authData[0].accountID
const auth = await Database.use((tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
const accountWorkspaces = await Database.use((tx) =>
tx
.select({
userID: UserTable.id,
workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name,
balance: BillingTable.balance,
role: UserTable.role,
black: SubscriptionTable.timeCreated,
lite: LiteTable.timeCreated,
})
.from(UserTable)
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.leftJoin(BillingTable, eq(BillingTable.workspaceID, WorkspaceTable.id))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
.where(eq(UserTable.accountID, accountID))
.then((rows) =>
rows.map((row) => ({
workspaceName: row.workspaceID
? { __link: `#workspace-${row.workspaceID}`, label: row.workspaceName }
: row.workspaceName,
userID: row.userID,
workspaceID: row.workspaceID,
balance: formatMicroCents(row.balance) ?? "$0.00",
role: row.role,
black: formatDate(row.black),
lite: formatDate(row.lite),
})),
),
)
const workspaces: WorkspaceSection[] = []
for (const w of accountWorkspaces) {
if (!w.workspaceID) continue
workspaces.push(await loadWorkspace(w.workspaceID))
}
return {
identifier,
auth: auth.map((row) => ({
provider: row.provider,
subject: row.subject,
accountID: row.accountID,
})),
accountWorkspaces,
workspaces,
}
}
async function loadWorkspace(workspaceID: string): Promise<WorkspaceSection> {
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
if (!workspace) throw new Error(`Workspace ${workspaceID} not found`)
const users = await Database.use((tx) =>
tx
.select({
authEmail: AuthTable.subject,
inviteEmail: UserTable.email,
role: UserTable.role,
timeSeen: UserTable.timeSeen,
monthlyLimit: UserTable.monthlyLimit,
monthlyUsage: UserTable.monthlyUsage,
timeDeleted: UserTable.timeDeleted,
fixedUsage: SubscriptionTable.fixedUsage,
rollingUsage: SubscriptionTable.rollingUsage,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeSubscriptionCreated: SubscriptionTable.timeCreated,
subscription: BillingTable.subscription,
})
.from(UserTable)
.innerJoin(BillingTable, eq(BillingTable.workspaceID, workspace.id))
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.where(eq(UserTable.workspaceID, workspace.id))
.then((rows) =>
rows.map((row) => {
const subStatus = getSubscriptionStatus(row)
return {
email: (row.timeDeleted ? "[deleted] " : "") + (row.authEmail ?? row.inviteEmail),
role: row.role,
timeSeen: formatDate(row.timeSeen),
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
subscribed: formatDate(row.timeSubscriptionCreated),
subWeekly: subStatus.weekly,
subRolling: subStatus.rolling,
rateLimited: subStatus.rateLimited,
retryIn: subStatus.retryIn,
}
}),
),
)
const billing = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
customerID: BillingTable.customerID,
reload: BillingTable.reload,
blackSubscriptionID: BillingTable.subscriptionID,
blackSubscription: {
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
enrichment: BillingTable.subscription,
},
timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
liteSubscriptionID: BillingTable.liteSubscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
.then(
(rows) =>
rows.map((row) => ({
balance: `$${(row.balance / 100000000).toFixed(2)}`,
reload: row.reload ? "yes" : "no",
customerID: row.customerID,
GO: row.liteSubscriptionID,
Black: row.blackSubscriptionID
? [
`Black ${row.blackSubscription.enrichment!.plan}`,
row.blackSubscription.enrichment!.seats > 1
? `X ${row.blackSubscription.enrichment!.seats} seats`
: "",
row.blackSubscription.enrichment!.coupon
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
: "",
`(ref: ${row.blackSubscriptionID})`,
].join(" ")
: row.blackSubscription.booked
? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
: undefined,
}))[0] ?? null,
),
)
const liteLimits = LiteData.getLimits()
const go = await Database.use((tx) =>
tx
.select({
userID: LiteTable.userID,
userEmail: UserTable.email,
authEmail: AuthTable.subject,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
useBalance: BillingTable.lite,
})
.from(LiteTable)
.innerJoin(BillingTable, eq(BillingTable.workspaceID, LiteTable.workspaceID))
.leftJoin(UserTable, eq(UserTable.id, LiteTable.userID))
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(LiteTable.workspaceID, workspace.id), isNull(LiteTable.timeDeleted)))
.then((rows) =>
rows.map((row) => {
const rolling = Subscription.analyzeRollingUsage({
limit: liteLimits.rollingLimit,
window: liteLimits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
})
const weekly = Subscription.analyzeWeeklyUsage({
limit: liteLimits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
})
const monthly = Subscription.analyzeMonthlyUsage({
limit: liteLimits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
})
return {
email: row.authEmail ?? row.userEmail ?? row.userID,
subscribed: formatDate(row.timeCreated),
useBalance: row.useBalance?.useBalance ? "yes" : "no",
rolling: formatLiteUsage(rolling),
weekly: formatLiteUsage(weekly),
monthly: formatLiteUsage(monthly),
}
}),
),
)
const payments = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
paymentID: PaymentTable.paymentID,
invoiceID: PaymentTable.invoiceID,
customerID: PaymentTable.customerID,
timeCreated: PaymentTable.timeCreated,
timeRefunded: PaymentTable.timeRefunded,
})
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, workspace.id))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100)
.then((rows) =>
rows.map((row) => ({
amount: `$${(row.amount / 100000000).toFixed(2)}`,
paymentID: row.paymentID
? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
: null,
invoiceID: row.invoiceID,
customerID: row.customerID,
timeCreated: formatDate(row.timeCreated),
timeRefunded: formatDate(row.timeRefunded),
})),
),
)
const planExpr = sql`JSON_UNQUOTE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan'))`
const usage = await Database.use((tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
freeRequests: sql<number>`SUM(CASE WHEN ${UsageTable.cost} = 0 THEN 1 ELSE 0 END)`.as("free_requests"),
goRequests: sql<number>`SUM(CASE WHEN ${planExpr} = 'lite' THEN 1 ELSE 0 END)`.as("go_requests"),
goCost: sql<number>`SUM(CASE WHEN ${planExpr} = 'lite' THEN ${UsageTable.cost} ELSE 0 END)`.as("go_cost"),
apiRequests: sql<number>`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN 1 ELSE 0 END)`.as(
"api_requests",
),
apiCost:
sql<number>`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN ${UsageTable.cost} ELSE 0 END)`.as(
"api_cost",
),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totals = rows.reduce(
(acc, r) => ({
freeRequests: acc.freeRequests + Number(r.freeRequests),
goRequests: acc.goRequests + Number(r.goRequests),
goCost: acc.goCost + Number(r.goCost),
apiRequests: acc.apiRequests + Number(r.apiRequests),
apiCost: acc.apiCost + Number(r.apiCost),
}),
{ freeRequests: 0, goRequests: 0, goCost: 0, apiRequests: 0, apiCost: 0 },
)
const mapped: Record<string, unknown>[] = rows.map((row) => ({
date: row.date,
freeRequests: Number(row.freeRequests),
goRequests: Number(row.goRequests),
goCost: formatMicroCents(Number(row.goCost)) ?? "$0.00",
apiRequests: Number(row.apiRequests),
apiCost: formatMicroCents(Number(row.apiCost)) ?? "$0.00",
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
freeRequests: totals.freeRequests,
goRequests: totals.goRequests,
goCost: formatMicroCents(totals.goCost) ?? "$0.00",
apiRequests: totals.apiRequests,
apiCost: formatMicroCents(totals.apiCost) ?? "$0.00",
})
}
return mapped
}),
)
const disabledModels = await Database.use((tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
return {
workspaceID: workspace.id,
title: `Workspace "${workspace.name}" (${workspace.id})`,
users,
billing,
go,
payments,
usage,
disabledModels,
}
}
function formatLiteUsage(usage: { status: "ok" | "rate-limited"; usagePercent: number; resetInSec: number }) {
const reset = formatResetTime(usage.resetInSec)
const status = usage.status === "rate-limited" ? " [limited]" : ""
return `${usage.usagePercent}% (resets in ${reset})${status}`
}
function formatResetTime(seconds: number) {
if (seconds <= 0) return "now"
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days}d`
const hours = Math.floor(seconds / 3600)
if (hours >= 1) {
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
}
const minutes = Math.max(1, Math.ceil(seconds / 60))
return `${minutes}m`
}
function formatMicroCents(value: number | null | undefined) {
if (value === null || value === undefined) return null
return `$${(value / 100000000).toFixed(2)}`
}
function formatDate(value: Date | null | undefined) {
if (!value) return null
return value.toISOString().split("T")[0]
}
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
const usageText = formatMicroCents(usage) ?? "$0.00"
if (limit === null || limit === undefined) return `${usageText} / no limit`
return `${usageText} / $${limit.toFixed(2)}`
}
function formatRetryTime(seconds: number) {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
}
function getSubscriptionStatus(row: {
subscription: {
plan: (typeof BlackPlans)[number]
} | null
timeSubscriptionCreated: Date | null
fixedUsage: number | null
rollingUsage: number | null
timeFixedUpdated: Date | null
timeRollingUpdated: Date | null
}) {
if (!row.timeSubscriptionCreated || !row.subscription) {
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
}
const black = BlackData.getLimits({ plan: row.subscription.plan })
const now = new Date()
const week = getWeekBounds(now)
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
const currentWeekly =
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
const windowStart = new Date(now.getTime() - rollingWindowMs)
const currentRolling =
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
const retryIn = isWeeklyLimited
? formatRetryTime(Math.ceil((week.end.getTime() - now.getTime()) / 1000))
: isRollingLimited && row.timeRollingUpdated
? formatRetryTime(Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000))
: null
return {
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
retryIn,
}
}
@@ -0,0 +1,22 @@
import { Title } from "@solidjs/meta"
export default function SupportPage() {
return (
<main data-page="support">
<Title>opencode support lookup user</Title>
<h1>Lookup user</h1>
<form data-component="lookup" action="/lookup" method="get" target="_blank">
<input
type="text"
name="identifier"
placeholder="email, wrk_..., key_..., or sk-..."
autocomplete="off"
autofocus
required
/>
<button type="submit">Lookup</button>
</form>
</main>
)
}
@@ -0,0 +1,39 @@
import { Title } from "@solidjs/meta"
import { createAsync, query, useSearchParams, type RouteDefinition } from "@solidjs/router"
import { Show } from "solid-js"
import { ErrorBoundary } from "solid-js"
import { Result } from "~/component/result"
import { lookup } from "~/lib/lookup"
const getLookup = query(async (identifier: string) => {
"use server"
return lookup(identifier)
}, "support.lookup")
export const route: RouteDefinition = {
preload: ({ location }) => {
const identifier = new URLSearchParams(location.search).get("identifier")?.trim()
if (identifier) void getLookup(identifier)
},
}
export default function LookupPage() {
const [params] = useSearchParams()
const identifier = () => String(params.identifier ?? "").trim()
const data = createAsync(() => (identifier() ? getLookup(identifier()) : Promise.resolve(undefined)))
return (
<main data-page="support">
<Title>opencode support {identifier() || "lookup"}</Title>
<h1>Lookup: {identifier() || "(no identifier)"}</h1>
<Show when={identifier()} fallback={<div data-empty>Provide an `identifier` query parameter.</div>}>
<ErrorBoundary fallback={(err) => <div data-component="error">{(err as Error).message}</div>}>
<Show when={data()} fallback={<div data-empty>Loading...</div>}>
{(result) => <Result data={result()} />}
</Show>
</ErrorBoundary>
</Show>
</main>
)
}
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vite/client", "bun"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, PluginOption } from "vite"
import { solidStart } from "@solidjs/start/config"
import { nitro } from "nitro/vite"
export default defineConfig({
plugins: [
solidStart() as PluginOption,
nitro({
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
nodeCompat: true,
},
}),
],
server: {
allowedHosts: true,
},
build: {
rollupOptions: {
external: ["cloudflare:workers"],
},
minify: false,
},
})
+1 -1
View File
@@ -43,7 +43,7 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
+1 -1
View File
@@ -79,7 +79,7 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
+20
View File
@@ -4,10 +4,14 @@ import {
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
@@ -44,6 +48,22 @@ export class Agent implements ACPAgent {
return run(this.service.loadSession(params))
}
listSessions(params: ListSessionsRequest) {
return run(this.service.listSessions(params))
}
resumeSession(params: ResumeSessionRequest) {
return run(this.service.resumeSession(params))
}
closeSession(params: CloseSessionRequest) {
return run(this.service.closeSession(params))
}
unstable_forkSession(params: ForkSessionRequest) {
return run(this.service.forkSession(params))
}
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
return run(this.service.setSessionConfigOption(params))
}
+311
View File
@@ -0,0 +1,311 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type {
Event,
EventMessagePartDelta,
EventMessagePartUpdated,
OpencodeClient,
Part,
SessionMessageResponse,
ToolPart,
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
shellOutputSnapshot,
completedToolUpdate,
} from "./tool"
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type GlobalEventEnvelope = {
payload?: Event
}
type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope>
}
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) {
const subscription = new Subscription(input)
subscription.start()
return subscription
}
export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private started = false
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
start() {
if (this.started) return
this.started = true
this.run().catch((error: unknown) => {
if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
stop() {
this.abort.abort()
}
async handle(event: Event) {
switch (event.type) {
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
return this.handlePartDelta(event)
}
}
async replayMessage(message: SessionMessageResponse) {
if (message.info.role !== "assistant" && message.info.role !== "user") return
for (const part of message.parts) {
await this.recordFetchedPart(message.info.sessionID, message, part)
if (part.type === "tool") {
await this.handleToolPart(message.info.sessionID, part)
}
}
}
private async run() {
while (!this.abort.signal.aborted) {
const events = (await this.input.sdk.global.event({
signal: this.abort.signal,
})) as GlobalEventStream
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch((error: unknown) => {
log.error("failed to handle event", { error, type: event.payload?.type })
})
}
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
private async handlePartUpdated(event: EventMessagePartUpdated) {
const part = event.properties.part
const sessionId = part.sessionID || event.properties.sessionID
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
if (!session) return
await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId: session.id,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: part.type === "reasoning" ? "assistant" : undefined,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
if (part.type === "tool") {
await this.handleToolPart(session.id, part)
}
}
private async handlePartDelta(event: EventMessagePartDelta) {
const props = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
if (!session) return
const known = await Effect.runPromise(
this.input.session.tryGetPartMetadata({
sessionId: session.id,
messageId: props.messageID,
partId: props.partID,
}),
)
const metadata =
known?.role && known.partType
? known
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
if (metadata?.role !== "assistant") return
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_message_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
return
}
if (metadata.partType === "reasoning" && props.field === "text") {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
}
}
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
const message = await this.input.sdk.session
.message(
{
sessionID: sessionId,
messageID: messageId,
directory: cwd,
},
{ throwOnError: true },
)
.then((response) => response.data)
.catch((error: unknown) => {
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return
const part = message.parts.find((item) => item.id === partId)
if (!part) return
return await this.recordFetchedPart(sessionId, message, part)
}
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
return await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: message.info.role,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
}
private async handleToolPart(sessionId: string, part: ToolPart) {
await this.toolStart(sessionId, part)
switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(part.callID)
return
case "running":
await this.runningTool(sessionId, part)
return
case "completed":
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
case "error":
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
}
}
private async runningTool(sessionId: string, part: ToolPart) {
if (part.state.status !== "running") return
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
if (output !== undefined) {
if (this.shellSnapshots.get(part.callID) === output) {
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...duplicateRunningToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
}
this.shellSnapshots.set(part.callID, output)
}
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
output,
}),
},
})
}
private async toolStart(sessionId: string, part: ToolPart) {
if (this.toolStarts.has(part.callID)) return
this.toolStarts.add(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.callID,
toolName: part.tool,
}),
},
})
}
private clearTool(toolCallId: string) {
this.toolStarts.delete(toolCallId)
this.shellSnapshots.delete(toolCallId)
}
}
export * as ACPNextEvent from "./event"
+181 -12
View File
@@ -4,8 +4,14 @@ import {
type AuthenticateResponse,
type AuthMethod,
type CancelNotification,
type CloseSessionRequest,
type CloseSessionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type McpServer,
@@ -13,6 +19,9 @@ import {
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionInfo,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModelRequest,
@@ -21,11 +30,13 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
@@ -33,6 +44,7 @@ import type { Command } from "@/command"
export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-next-service" })
export type Error = ACPNextError.Error
@@ -41,6 +53,10 @@ export type Interface = {
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Error>
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Error>
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Error>
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Error>
readonly setSessionConfigOption: (
input: SetSessionConfigOptionRequest,
) => Effect.Effect<SetSessionConfigOptionResponse, Error>
@@ -57,10 +73,15 @@ export function make(input: {
connection?: Pick<AgentSideConnection, "sessionUpdate">
directory?: Directory.Interface
session?: ACPNextSession.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const authMethod: AuthMethod = {
@@ -91,6 +112,12 @@ export function make(input: {
embeddedContext: true,
image: true,
},
sessionCapabilities: {
close: {},
fork: {},
list: {},
resume: {},
},
},
authMethods: [authMethod],
agentInfo: {
@@ -181,6 +208,137 @@ export function make(input: {
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
configOptions: configOptions(snapshot, {
model: state.model ?? model,
variant: state.variant,
modeId: state.modeId,
}),
}
})
const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) {
const cursor = params.cursor ? Number(params.cursor) : undefined
const limit = 100
const sessions = yield* request(
() =>
input.sdk.session.list(
{
...(params.cwd ? { directory: params.cwd } : {}),
roots: true,
},
{ throwOnError: true },
),
"session",
)
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
const filtered =
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
const page = filtered.slice(0, limit)
const last = page.at(-1)
return {
sessions: page.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
),
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
}
})
const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
"session",
)
const messages = yield* request(
() =>
input.sdk.session.messages(
{ directory: params.cwd, sessionID: params.sessionId, limit: 20 },
{ throwOnError: true },
),
"session",
)
const restored = restoreFromMessages(messages.map((item) => item.info))
const model = restored.model ?? selectDefaultModel(snapshot)
const state = yield* session.load({
id: params.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
model,
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
configOptions: configOptions(snapshot, {
model: state.model ?? model,
variant: state.variant,
modeId: state.modeId,
}),
}
})
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
if (!removed) return {}
yield* request(
() => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }),
"session",
).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
}),
),
)
return {}
})
const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
const forked = yield* request(
() =>
input.sdk.session.fork(
{
directory: params.cwd,
sessionID: params.sessionId,
},
{ throwOnError: true },
),
"session",
)
const messages = yield* request(
() =>
input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }),
"session",
)
const restored = restoreFromMessages(messages.map((item) => item.info))
const model = restored.model ?? selectDefaultModel(snapshot)
const state = yield* session.load({
id: forked.id,
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
model,
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
sessionId: state.id,
@@ -279,6 +437,10 @@ export function make(input: {
authenticate,
newSession,
loadSession,
listSessions,
resumeSession,
closeSession,
forkSession,
setSessionConfigOption,
setSessionMode,
setSessionModel,
@@ -312,6 +474,17 @@ function makeDirectoryService(sdk: OpencodeClient) {
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
for (const message of messages) {
await subscription.replayMessage(message).catch((error: unknown) => {
log.error("failed to replay ACP message", { error, messageID: message.info.id })
})
}
})
}
type ConfigState = {
readonly model: Directory.DefaultModel
readonly variant?: string
@@ -324,17 +497,13 @@ type SdkResponse<T> = {
}
type MessageInfo = {
readonly role?: string
readonly model?: {
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
}
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
readonly mode?: string
readonly agent?: string
readonly role?: Message["role"]
readonly model?: Extract<Message, { role: "user" }>["model"]
readonly providerID?: Extract<Message, { role: "assistant" }>["providerID"]
readonly modelID?: Extract<Message, { role: "assistant" }>["modelID"]
readonly variant?: Extract<Message, { role: "assistant" }>["variant"]
readonly mode?: Extract<Message, { role: "assistant" }>["mode"]
readonly agent?: Message["agent"]
}
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
+10
View File
@@ -1,4 +1,5 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, Ref } from "effect"
import type { ProviderV2 } from "@opencode-ai/core/provider"
import * as ACPNextError from "./error"
@@ -12,6 +13,9 @@ export type SelectedModel = {
export type KnownMessagePartMetadata = {
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
@@ -41,6 +45,9 @@ export type RecordPartMetadataInput = {
sessionId: string
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
@@ -147,6 +154,9 @@ export const layer = Layer.effect(
const metadata = {
messageId: input.messageId,
partId: input.partId,
partType: input.partType,
role: input.role,
ignored: input.ignored,
toolCallId: input.toolCallId,
metadata: input.metadata,
}
+117 -1
View File
@@ -1,4 +1,4 @@
import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
@@ -16,6 +16,19 @@ export type CompletedToolState = {
readonly attachments?: ReadonlyArray<ToolAttachment>
}
export type RunningToolState = {
readonly status: "running"
readonly input: ToolInput
readonly title?: string
}
export type ErrorToolState = {
readonly status: "error"
readonly input: ToolInput
readonly error: string
readonly metadata?: unknown
}
export type ImageAttachment = {
readonly mimeType: string
readonly data: string
@@ -100,6 +113,104 @@ export function completedToolContent(toolName: string, state: CompletedToolState
return content
}
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: [],
rawInput: {},
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
readonly output?: string
}): ToolCallUpdate {
const content = input.output
? [
{
type: "content" as const,
content: {
type: "text" as const,
text: input.output,
},
},
]
: undefined
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
...(content ? { content } : {}),
}
}
export function duplicateRunningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: CompletedToolState & { readonly title: string }
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
kind: toToolKind(input.toolName),
title: input.state.title,
content: completedToolContent(input.toolName, input.state),
rawInput: input.state.input,
rawOutput: completedToolRawOutput(input.state),
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: ErrorToolState
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: input.toolName,
rawInput: input.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: input.state.error,
},
},
],
rawOutput: {
error: input.state.error,
metadata: input.state.metadata,
},
}
}
export function completedToolRawOutput(state: CompletedToolState) {
return {
output: state.output,
@@ -138,6 +249,11 @@ export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
export const buildCompletedRawOutput = completedToolRawOutput
export const extractShellOutputSnapshot = shellOutputSnapshot
export const buildPendingToolCall = pendingToolCall
export const buildRunningToolUpdate = runningToolUpdate
export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate
export const buildCompletedToolUpdate = completedToolUpdate
export const buildErrorToolUpdate = errorToolUpdate
function locationFrom(value: unknown): ToolCallLocation[] {
const path = stringValue(value)
+6 -18
View File
@@ -1,5 +1,6 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -8,27 +9,14 @@ import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-next-usage" })
export type AssistantTokenCost = {
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: {
readonly read: number
readonly write: number
}
}
}
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost & {
readonly role: "assistant"
readonly providerID?: string
readonly modelID?: string
}
export type AssistantMessage = AssistantTokenCost &
Pick<OpenCodeAssistantMessage, "role"> &
Partial<Pick<OpenCodeAssistantMessage, "providerID" | "modelID">>
export type SessionMessage = {
readonly info: { readonly role: string } | AssistantMessage
readonly info: { readonly role: Message["role"] } | AssistantMessage
}
export type MessagesInput = {
+8 -1
View File
@@ -762,7 +762,14 @@ export const layer = Layer.effect(
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (!result.username) {
try {
result.username = os.userInfo().username || "user"
} catch (err) {
log.warn("failed to read system username, using fallback", { err })
result.username = "user"
}
}
if (result.autoshare === true && !result.share) {
result.share = "auto"
+8 -1
View File
@@ -46,7 +46,14 @@ export function parseManagedPlist(json: string): string {
export async function readManagedPreferences() {
if (process.platform !== "darwin") return
const user = os.userInfo().username
const user = (() => {
try {
return os.userInfo().username || "user"
} catch (err) {
log.warn("failed to read system username, using fallback", { err })
return "user"
}
})()
const paths = [
path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`),
path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`),
@@ -0,0 +1,657 @@
import { describe, expect, it } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPNextEvent } from "@/acp-next/event"
import * as ACPNextService from "@/acp-next/service"
import { Directory } from "@/acp-next/directory"
import { ACPNextSession } from "@/acp-next/session"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type ToolSessionUpdateParams = SessionUpdateParams & {
update: Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call" | "tool_call_update" }>
}
type GlobalEventEnvelope = {
payload?: Event
}
type DeltaPartType = Extract<Part, { type: "text" | "reasoning" }>["type"]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)),
)
}
function createEventStream() {
const queue: GlobalEventEnvelope[] = []
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
const state = { closed: false }
const push = (event: GlobalEventEnvelope) => {
const waiter = waiters.shift()
if (waiter) {
waiter(event)
return
}
queue.push(event)
}
const close = () => {
state.closed = true
for (const waiter of waiters.splice(0)) {
waiter(undefined)
}
}
const stream = async function* (signal?: AbortSignal) {
while (true) {
if (signal?.aborted) return
const next = queue.shift()
if (next) {
yield next
continue
}
if (state.closed) return
const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
waiters.push(resolve)
signal?.addEventListener("abort", () => resolve(undefined), { once: true })
})
if (!value) return
yield value
}
}
return { push, close, stream }
}
function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
const updates: SessionUpdateParams[] = []
const calls = {
eventSubscribe: 0,
message: 0,
}
const events = createEventStream()
const sdk = {
global: {
event: (options?: { signal?: AbortSignal }) => {
calls.eventSubscribe++
return Promise.resolve({ stream: events.stream(options?.signal) })
},
},
session: {
message: (input: { messageID: string }) => {
calls.message++
return Promise.resolve({ data: messages[input.messageID] })
},
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
messages: () => Promise.resolve({ data: [] }),
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
const session = makeSessionService()
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
return { calls, connection, events, sdk, session, subscription, updates }
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string): Event {
return {
id: `evt_${sessionID}_${messageID}_${partID}_${delta}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
}
}
function partUpdated(sessionID: string, messageID: string, partID: string, type: DeltaPartType): Event {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.updated",
properties: {
sessionID,
time: Date.now(),
part:
type === "text"
? {
id: partID,
sessionID,
messageID,
type: "text",
text: "",
}
: {
id: partID,
sessionID,
messageID,
type: "reasoning",
text: "",
time: { start: Date.now() },
},
},
}
}
function toolUpdated(part: ToolPart): Event {
return {
id: `evt_${part.sessionID}_${part.messageID}_${part.id}_${part.state.status}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
time: Date.now(),
part,
},
}
}
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
type === "text"
? {
id: partID,
sessionID,
messageID,
type: "text",
text: "",
}
: {
id: partID,
sessionID,
messageID,
type: "reasoning",
text: "",
time: { start: Date.now() },
},
],
} satisfies SessionMessageResponse
}
function assistantToolMessage(part: ToolPart) {
return {
info: {
id: part.messageID,
sessionID: part.sessionID,
role: "assistant",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [part],
} satisfies SessionMessageResponse
}
function runningTool(
sessionID: string,
callID: string,
output?: string,
input: Record<string, unknown> = { cmd: "printf hello" },
) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "running",
input,
title: "bash",
...(output !== undefined ? { metadata: { output } } : {}),
time: { start: Date.now() },
},
} satisfies ToolPart
}
function completedTool(
sessionID: string,
callID: string,
output = "done",
attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "completed",
input: { cmd: "printf done" },
output,
title: "bash",
metadata: { exit: 0 },
time: { start: Date.now() - 1, end: Date.now() },
...(attachments.length ? { attachments } : {}),
},
} satisfies ToolPart
}
function errorTool(sessionID: string, callID: string) {
return {
id: `part_${callID}`,
sessionID,
messageID: `msg_${callID}`,
type: "tool",
callID,
tool: "bash",
state: {
status: "error",
input: { cmd: "exit 1" },
error: "failed hard",
metadata: { exit: 1 },
time: { start: Date.now() - 1, end: Date.now() },
},
} satisfies ToolPart
}
function toolUpdates(updates: SessionUpdateParams[]) {
return updates.filter((item): item is ToolSessionUpdateParams => {
return item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
})
}
async function createKnownSession(
session: ACPNextSession.Interface,
sessionId: string,
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
) {
await Effect.runPromise(session.create({ id: sessionId, cwd: "/workspace" }))
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId: part.messageId,
partId: part.partId,
partType: part.partType,
role: part.role ?? "assistant",
}),
)
}
describe("acp-next event routing", () => {
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
await createKnownSession(harness.session, "ses_b", { messageId: "msg_b", partId: "part_b", partType: "text" })
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "hello"))
expect(harness.updates.map((update) => update.sessionId)).toEqual(["ses_b"])
expect(harness.updates[0]?.update.sessionUpdate).toBe("agent_message_chunk")
})
it("keeps interleaved sessions isolated for text and reasoning deltas", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
await createKnownSession(harness.session, "ses_b", {
messageId: "msg_b",
partId: "part_b",
partType: "reasoning",
})
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A1"))
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B1"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A2"))
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B2"))
expect(
harness.updates.filter((update) => update.sessionId === "ses_a").map((update) => update.update.sessionUpdate),
).toEqual(["agent_message_chunk", "agent_message_chunk"])
expect(
harness.updates.filter((update) => update.sessionId === "ses_b").map((update) => update.update.sessionUpdate),
).toEqual(["agent_thought_chunk", "agent_thought_chunk"])
})
it("does not create extra subscriptions on repeated loadSession", async () => {
const harness = createHarness()
let subscription: ACPNextEvent.Subscription | undefined
const service = ACPNextService.make({
sdk: harness.sdk,
connection: harness.connection,
directory: {
get: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
refresh: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
variants: Directory.variants,
},
session: harness.session,
eventSubscription: (started) => {
subscription = started
},
})
await pollUntil(() => harness.calls.eventSubscribe === 1, "event subscription did not start")
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(harness.calls.eventSubscribe).toBe(1)
subscription?.stop()
harness.events.close()
})
it("does not call sdk.session.message repeatedly when metadata is known", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
for (const delta of ["a", "b", "c", "d", "e"]) {
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", delta))
}
expect(harness.calls.message).toBe(0)
expect(harness.updates).toHaveLength(5)
})
it("fetches unknown part metadata once and reuses it for later deltas", async () => {
const harness = createHarness({
msg_a: assistantMessage("ses_a", "msg_a", "part_a", "text"),
})
await Effect.runPromise(harness.session.create({ id: "ses_a", cwd: "/workspace" }))
await harness.subscription.handle(partUpdated("ses_a", "msg_a", "part_a", "text"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "a"))
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "b"))
expect(harness.calls.message).toBe(1)
expect(harness.updates).toHaveLength(2)
})
it("replays loaded session messages sequentially and continues after update failures", async () => {
const events = createEventStream()
const updates: SessionUpdateParams[] = []
const connection = {
sessionUpdate: (params: SessionUpdateParams) => {
if (params.update.sessionUpdate === "tool_call" && params.update.toolCallId === "call_slow") {
return new Promise<void>((resolve) => {
setTimeout(() => {
updates.push(params)
resolve()
}, 20)
})
}
if (params.update.sessionUpdate === "tool_call_update" && params.update.toolCallId === "call_slow") {
return Promise.reject(new Error("replay send failed"))
}
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
let subscription: ACPNextEvent.Subscription | undefined
const service = ACPNextService.make({
sdk: {
global: {
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
},
session: {
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
messages: () =>
Promise.resolve({
data: [
assistantToolMessage(completedTool("ses_loaded", "call_slow", "slow")),
assistantToolMessage(completedTool("ses_loaded", "call_after", "after")),
],
}),
},
} as unknown as OpencodeClient,
connection,
directory: {
get: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
refresh: () =>
Effect.succeed(
Directory.build({
directory: "/workspace",
providers: {},
modes: [],
defaultModeID: "build",
commands: [],
}),
),
variants: Directory.variants,
},
eventSubscription: (started) => {
subscription = started
},
})
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
expect(toolUpdates(updates).map((item) => item.update.toolCallId)).toEqual([
"call_slow",
"call_after",
"call_after",
])
subscription?.stop()
events.close()
})
it("ignores unknown sessions and live user parts without user_message_chunk duplication", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_user", {
messageId: "msg_user",
partId: "part_user",
partType: "text",
role: "user",
})
await harness.subscription.handle(textDelta("ses_missing", "msg_missing", "part_missing", "ignored"))
await harness.subscription.handle(partUpdated("ses_user", "msg_user", "part_live", "text"))
await harness.subscription.handle(textDelta("ses_user", "msg_user", "part_user", "hello"))
expect(harness.updates).toHaveLength(0)
})
it("emits synthetic pending before the first running tool update", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_tool", "call_1", "hello")))
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
"tool_call",
"tool_call_update",
])
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
})
it("does not emit duplicate synthetic pending after a replayed running tool", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" }))
await harness.subscription.replayMessage(assistantToolMessage(runningTool("ses_replay", "call_replay", "first")))
await harness.subscription.handle(toolUpdated(runningTool("ses_replay", "call_replay", "second")))
expect(toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")).toHaveLength(1)
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
"tool_call",
"tool_call_update",
"tool_call_update",
])
})
it("dedupes shell output snapshots while still sending status-only running updates", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_shell", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
const updates = toolUpdates(harness.updates)
expect(updates).toHaveLength(3)
expect(updates[1]?.update).toMatchObject({
sessionUpdate: "tool_call_update",
content: [{ type: "content", content: { type: "text", text: "same" } }],
})
expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" })
expect("content" in updates[2]!.update).toBe(false)
})
it("clears shell snapshot marker when a tool returns to pending", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_pending", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
await harness.subscription.handle(
toolUpdated({
id: "part_call_pending",
sessionID: "ses_pending",
messageID: "msg_call_pending",
type: "tool",
callID: "call_pending",
tool: "bash",
state: {
status: "pending",
input: { cmd: "printf repeat" },
raw: '{"cmd":"printf repeat"}',
},
}),
)
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
expect(
toolUpdates(harness.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => ("content" in item.update ? item.update.content : undefined)),
).toEqual([
[{ type: "content", content: { type: "text", text: "repeat" } }],
[{ type: "content", content: { type: "text", text: "repeat" } }],
])
})
it("emits completed tool output and rawOutput", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_done", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(completedTool("ses_done", "call_done", "finished")))
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_done",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "finished" } }],
rawOutput: { output: "finished", metadata: { exit: 0 } },
})
})
it("emits error tool output", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(errorTool("ses_error", "call_error")))
expect(harness.updates.at(-1)?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call_error",
status: "failed",
content: [{ type: "content", content: { type: "text", text: "failed hard" } }],
rawOutput: { error: "failed hard", metadata: { exit: 1 } },
})
})
it("emits image attachments as ACP image content for live and replayed completed tool updates", async () => {
const harness = createHarness()
const image = Buffer.from("image-data").toString("base64")
const attachment = {
id: "file_image",
sessionID: "ses_image",
messageID: "msg_image",
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${image}`,
} as const
await Effect.runPromise(harness.session.create({ id: "ses_image", cwd: "/workspace" }))
await harness.subscription.handle(toolUpdated(completedTool("ses_image", "call_live", "live", [attachment])))
await harness.subscription.replayMessage(
assistantToolMessage(completedTool("ses_image", "call_replayed", "replayed", [attachment])),
)
expect(
toolUpdates(harness.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed")
.map((item) => ("content" in item.update ? item.update.content : [])),
).toEqual([
[
{ type: "content", content: { type: "text", text: "live" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
],
[
{ type: "content", content: { type: "text", text: "replayed" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
],
])
})
})
@@ -1,16 +1,19 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
ForkSessionResponse,
LoadSessionResponse,
NewSessionResponse,
ResumeSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { Effect, ManagedRuntime } from "effect"
import * as ACPNextService from "@/acp-next/service"
import * as ACPNextError from "@/acp-next/error"
import { ACPNextSession } from "@/acp-next/session"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { Provider } from "@/provider/provider"
@@ -140,6 +143,14 @@ describe("ACP next service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: unknown[] = []
const mcpAdds: string[] = []
const aborts: string[] = []
const forks: string[] = []
const sessions = Array.from({ length: 102 }, (_, index) => ({
id: `ses_${index + 1}`,
directory: index % 2 === 0 ? "/workspace" : "/other",
title: `Session ${index + 1}`,
time: { created: index + 1, updated: index + 1 },
}))
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
@@ -168,8 +179,19 @@ describe("ACP next service sessions", () => {
session: {
create: () => Promise.resolve({ data: { id: "ses_new" } }),
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
list: () => Promise.resolve({ data: [] }),
list: (input: { directory?: string }) =>
Promise.resolve({
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
abort: (input: { sessionID: string }) => {
aborts.push(input.sessionID)
return Promise.resolve({ data: true })
},
fork: (input: { sessionID: string }) => {
forks.push(input.sessionID)
return Promise.resolve({ data: { id: `fork_${input.sessionID}` } })
},
},
mcp: {
add: (input: { name?: string }) => {
@@ -185,7 +207,7 @@ describe("ACP next service sessions", () => {
},
} as Pick<AgentSideConnection, "sessionUpdate">
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds }
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks }
}
it("creates a backed session with config options and command update", async () => {
@@ -233,6 +255,125 @@ describe("ACP next service sessions", () => {
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
})
it("lists sessions sorted by updated time with cursor support", async () => {
const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
const second = await Effect.runPromise(service.listSessions({ cwd: "/workspace", cursor: first.nextCursor }))
expect(first.sessions).toHaveLength(51)
expect(first.sessions[0]?.sessionId).toBe("ses_101")
expect(first.sessions.at(-1)?.sessionId).toBe("ses_1")
expect(first.nextCursor).toBeUndefined()
expect(second.sessions).toEqual(first.sessions)
})
it("lists all sessions with next cursor when the first page is full", async () => {
const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({}))
const second = await Effect.runPromise(service.listSessions({ cursor: first.nextCursor }))
expect(first.sessions).toHaveLength(100)
expect(first.sessions[0]?.sessionId).toBe("ses_102")
expect(first.sessions.at(-1)?.sessionId).toBe("ses_3")
expect(first.nextCursor).toBe("3")
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
})
it("resumes a session and stores restored state", async () => {
const { service } = makeService([
{
info: {
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "high" },
agent: "plan",
},
parts: [],
},
])
const resumed = await Effect.runPromise(
service.resumeSession({ cwd: "/workspace", sessionId: "ses_resume", mcpServers: [] }),
)
const updated = await Effect.runPromise(
service.setSessionConfigOption({ sessionId: "ses_resume", configId: "effort", value: "default" }),
)
expect(select(resumed, "effort")?.currentValue).toBe("high")
expect(select(updated, "effort")?.currentValue).toBe("default")
})
it("closes local ACP state and aborts the backing session best-effort", async () => {
const { service, aborts } = makeService()
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
const missing = await Effect.runPromise(
service
.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
expect(missing.code).toBe(-32602)
expect(aborts).toEqual([created.sessionId])
expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
})
it("does not fail close when backing abort fails", async () => {
const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)),
)
const { service } = makeService()
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
abort: () => Promise.reject(new Error("nope")),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const closing = ACPNextService.make({ sdk, session: sessionService })
await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" }))
expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({})
expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
})
it("forks a session, loads fork state, and returns config options", async () => {
const { service, forks } = makeService([
{
info: {
role: "assistant",
providerID: "test",
modelID: "second-model",
variant: "medium",
mode: "plan",
},
parts: [],
},
])
const forked = await Effect.runPromise(
service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }),
)
const updated = await Effect.runPromise(
service.setSessionConfigOption({ sessionId: forked.sessionId, configId: "effort", value: "low" }),
)
expect(forked.sessionId).toBe("fork_ses_parent")
expect(select(forked, "model")?.currentValue).toBe("test/second-model")
expect(select(forked, "effort")?.currentValue).toBe("medium")
expect(select(updated, "effort")?.currentValue).toBe("low")
expect(forks).toEqual(["ses_parent"])
})
it("restores model variant and mode from the latest user message", async () => {
const { service } = makeService([
{
@@ -522,8 +663,11 @@ function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}
function select(result: SetSessionConfigOptionResponse, id: string) {
return result.configOptions.find(
function select(
result: SetSessionConfigOptionResponse | ResumeSessionResponse | NewSessionResponse | ForkSessionResponse,
id: string,
) {
return result.configOptions?.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
@@ -1,9 +1,11 @@
import { describe, expect } from "bun:test"
import type {
AuthenticateResponse,
CloseSessionResponse,
InitializeResponse,
LoadSessionResponse,
NewSessionResponse,
ResumeSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
@@ -31,7 +33,10 @@ describe("opencode acp-next (subprocess)", () => {
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
@@ -159,6 +164,55 @@ describe("opencode acp-next (subprocess)", () => {
60_000,
)
cliIt.live(
"advertises and supports close behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"advertises and supports resume behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"exits cleanly when flagged stdin is closed",
({ opencode }) =>
+16 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, describe, afterEach, beforeEach } from "bun:test"
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
import { Effect, Exit, Layer, Option } from "effect"
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
@@ -28,6 +28,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import path from "path"
import fs from "fs/promises"
import os from "os"
import { pathToFileURL } from "url"
import { Global } from "@opencode-ai/core/global"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -291,6 +292,20 @@ it.instance("loads config with defaults when no files exist", () =>
}),
)
it.instance("falls back to generic username when system user info is unavailable", () =>
Effect.gen(function* () {
const userInfo = spyOn(os, "userInfo").mockImplementation(() => {
throw Object.assign(new Error("missing passwd entry"), { code: "ENOENT" })
})
try {
const config = yield* Config.use.get()
expect(config.username).toBe("user")
} finally {
userInfo.mockRestore()
}
}),
)
it.effect("creates global jsonc config with schema when no global configs exist", () =>
withGlobalConfig({}, ({ dir }) =>
Effect.gen(function* () {
+6 -6
View File
@@ -52,8 +52,8 @@ export const syncStats: () => Effect.Effect<
discard: true,
})
yield* Effect.logInfo("stats sync complete").pipe(
Effect.annotateLogs({
yield* Effect.logInfo(
`stats sync complete ${JSON.stringify({
startedAt: startedAt.toISOString(),
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
@@ -61,7 +61,7 @@ export const syncStats: () => Effect.Effect<
providerRows: providerRows.length,
geoRows: geoRows.length,
stage: Resource.App.stage,
}),
})}`,
)
return {
@@ -74,8 +74,8 @@ export const syncStats: () => Effect.Effect<
})
function logRuntimeCheck() {
return Effect.logInfo("athena stats runtime check").pipe(
Effect.annotateLogs({
return Effect.logInfo(
`athena stats runtime check ${JSON.stringify({
catalog: Resource.InferenceEvent.catalog,
database: Resource.InferenceEvent.database,
dataset: Resource.StatsSyncConfig.dataset,
@@ -83,6 +83,6 @@ function logRuntimeCheck() {
workgroup: Resource.InferenceEvent.workgroup,
region: Resource.InferenceEvent.region,
stage: Resource.App.stage,
}),
})}`,
)
}
+33 -6
View File
@@ -34,24 +34,36 @@ export class Ingest extends Context.Service<Ingest, Ingest.Service>()("@opencode
if (events.length === 0) return { records: 0 }
const records = events.map(routeEvent).filter((event): event is RoutedEvent => Boolean(event))
if (records.length !== events.length) {
yield* Effect.logWarning(
`lake ingest rejected ${JSON.stringify({ records: events.length, unsupported: events.length - records.length })}`,
)
return yield* new IngestError({
message: "Unsupported lake event type",
failed: events.length - records.length,
})
}
const batches = chunks(
records.map((event) => ({ Data: Buffer.from(JSON.stringify(event)) })),
MAX_FIREHOSE_BATCH_SIZE,
)
yield* Effect.logInfo(
`lake ingest batch prepared ${JSON.stringify({ records: records.length, batches: batches.length })}`,
)
const failed = (yield* Effect.all(
chunks(
records.map((event) => ({ Data: Buffer.from(JSON.stringify(event)) })),
MAX_FIREHOSE_BATCH_SIZE,
).map((batch) => putRecords(client, Resource.LakeIngestConfig.streamName, batch)),
batches.map((batch) => putRecords(client, Resource.LakeIngestConfig.streamName, batch)),
{ concurrency: 8 },
)).reduce((sum, item) => sum + item, 0)
if (failed > 0) {
yield* Effect.logWarning(`lake ingest incomplete ${JSON.stringify({ records: records.length, failed })}`)
return yield* new IngestError({ message: "Failed to ingest all lake records", failed })
}
yield* Effect.logInfo(
`lake ingest complete ${JSON.stringify({ records: records.length, batches: batches.length })}`,
)
return { records: records.length }
})
@@ -75,7 +87,11 @@ const putRecords: (
try: () => client.send(new PutRecordBatchCommand({ DeliveryStreamName: streamName, Records: records })),
catch: (cause) =>
new IngestError({ message: "Failed to write lake records to Firehose", failed: records.length, cause }),
})
}).pipe(
Effect.tapError(() =>
Effect.logWarning(`firehose batch write failed ${JSON.stringify({ records: records.length, attempt })}`),
),
)
const failed =
result.RequestResponses?.flatMap((item, index) => {
const record = records[index]
@@ -83,9 +99,20 @@ const putRecords: (
return [record]
}) ?? []
yield* Effect.logInfo(
`firehose batch written ${JSON.stringify({ records: records.length, failed: failed.length, attempt })}`,
)
if (failed.length === 0) return 0
if (attempt >= MAX_FIREHOSE_ATTEMPTS) return failed.length
if (attempt >= MAX_FIREHOSE_ATTEMPTS) {
yield* Effect.logWarning(
`firehose batch failed ${JSON.stringify({ records: failed.length, attempts: MAX_FIREHOSE_ATTEMPTS })}`,
)
return failed.length
}
yield* Effect.logWarning(
`firehose batch retrying ${JSON.stringify({ records: failed.length, attempt: attempt + 1 })}`,
)
yield* Effect.sleep(`${250 * 2 ** (attempt - 1)} millis`)
return yield* putRecords(client, streamName, failed, attempt + 1)
})
+1 -1
View File
@@ -9,7 +9,7 @@ const SYNC_INTERVAL = "1 hour"
const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer)
const syncPass = syncStats().pipe(
Effect.catchCause((cause) =>
Effect.logWarning("stats sync failed").pipe(Effect.annotateLogs({ cause: Cause.pretty(cause) })),
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),
),
)
const daemon = Effect.logInfo("stats sync daemon started").pipe(
@@ -5,7 +5,6 @@
}
[data-component="icon-button-v2"] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
+20
View File
@@ -17,6 +17,18 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"sidebar-right": {
viewBox: "0 0 20 20",
body: `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
},
status: {
viewBox: "0 0 20 20",
body: `<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
},
"status-active": {
viewBox: "0 0 20 20",
body: `<path d="M18 2H2V10H18V2Z" fill="currentColor" fill-opacity="0.1"/><path d="M2 18H18V10H2V18Z" fill="currentColor" fill-opacity="0.1"/><path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
},
"magnifying-glass": {
viewBox: "0 0 16 16",
body: `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,
@@ -37,6 +49,14 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor"/>`,
},
"outline-chevron-down": {
viewBox: "0 0 16 16",
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
},
"outline-dots": {
viewBox: "0 0 16 16",
body: `<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor"/><path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor"/><path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor"/>`,
},
}
const spriteID = "opencode-v2-icon-sprite"
+2
View File
@@ -92,6 +92,8 @@
--v2-illustration-illustration-layer-01: var(--v2-grey-300);
--v2-illustration-illustration-layer-02: var(--v2-grey-400);
--v2-illustration-illustration-layer-03: var(--v2-grey-500);
--font-family-text: "Inter", sans-serif;
}
/* OS preference fallback (no JS needed) */
+1 -2
View File
@@ -153,7 +153,6 @@ declare module "sst" {
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Stat": import("@cloudflare/workers-types").Service
"StatsDatabase": {
@@ -345,4 +344,4 @@ declare module "sst" {
}
import "sst"
export {}
export {}
+9 -17
View File
@@ -2,28 +2,21 @@
export default $config({
app(input) {
// Dev owns the shared AWS lake/stats infra for all non-production stages.
const awsStage = input.stage === "production" ? "production" : "dev"
const deployAws = input.stage === awsStage
return {
name: "opencode",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "cloudflare",
providers: {
...(deployAws
? {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
}
: {}),
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
@@ -51,7 +44,6 @@ export default $config({
StatWorkerUrl: stat.url,
// StatsUrl: stats.app.url,
AwsStage: stage.awsStage,
...(stage.githubActionsDeployRoleArn ? { GithubActionsDeployRoleArn: stage.githubActionsDeployRoleArn } : {}),
}
},
})