Compare commits

..
Author SHA1 Message Date
Hona 62daf48a8a feat(app): preview Code Mode child calls 2026-09-18 01:30:50 +00:00
125 changed files with 1004 additions and 3419 deletions
-1
View File
@@ -93,7 +93,6 @@
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -485,7 +485,6 @@ export function stepStarted(message: SessionMessageAssistant) {
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
started: message.time.created,
})
}
@@ -108,15 +108,6 @@ test("non-Git folders show their status without offering worktree actions", asyn
).toBeEnabled()
})
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
const mock = await openDraft(page, "create", { currentDirectory: workspace })
await page.getByRole("button", { name: "New worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
await page.locator('[data-component="composer-editor"]').fill("Run locally")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.calls.find((call) => call.type === "session")?.directory).toBe(directory)
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
@@ -305,12 +296,7 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
expect(attempts).toHaveLength(1)
})
async function openDraft(
page: Page,
worktree = "main",
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
) {
const currentDirectory = options.currentDirectory ?? directory
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
const project = {
id: "proj_new_summary",
worktree: directory,
@@ -329,7 +315,7 @@ async function openDraft(
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory: currentDirectory,
directory,
project,
sessions,
provider: {
@@ -456,7 +442,7 @@ async function openDraft(
},
)
await page.addInitScript(
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
@@ -469,12 +455,12 @@ async function openDraft(
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
{ directory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
@@ -1,83 +0,0 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the hosted app hands HTTP-only pairing links over to the server's own web UI", async ({ page, baseURL }) => {
const dev = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const hosted = "https://app.opencode.ai"
const lan = "http://192.168.1.20:49374"
// Serve the dev build under the hosted HTTPS origin so mixed-content rules apply to the page.
await page.route(`${hosted}/**`, async (route) => {
const response = await page.request.fetch(route.request().url().replace(hosted, dev))
await route.fulfill({ response })
})
await page.route(`${lan}/**`, (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "<title>served</title>" }),
)
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
const info = { urls: [lan], username: "opencode", password: "lan-secret" }
const fragment = Buffer.from(JSON.stringify(info)).toString("base64url")
await page.goto(`${hosted}/connect#${fragment}`)
await expect(page.getByRole("heading", { name: "This server is on a local network" })).toBeVisible()
await expect(page.getByText(lan, { exact: true })).toBeVisible()
expect(requests).toEqual([])
await page.getByRole("button", { name: "Open on local network" }).click()
await expect(page).toHaveURL(`${lan}/connect#${fragment}`)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
@@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(thinking).toHaveCount(0)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
@@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) {
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
+1 -2
View File
@@ -93,7 +93,6 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3"
"tailwindcss": "4.3.3"
}
}
+16 -24
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,29 +107,21 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
return (
<ServersProvider
@@ -16,86 +16,6 @@
}
}
[data-component="upload-row"] {
display: flex;
flex-direction: column;
gap: 4px;
& + & {
margin-top: 8px;
}
[data-slot="upload-row-label"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
}
[data-slot="upload-row-name"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--v2-text-text-base);
font-weight: 530;
}
[data-slot="upload-row-percent"] {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-variant-numeric: tabular-nums;
}
[data-slot="upload-row-track"] {
display: flex;
align-items: center;
gap: 8px;
}
[data-component="upload-progress"] {
flex: 1;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--v2-background-bg-layer-02);
}
[data-slot="upload-progress-bar"] {
height: 100%;
border-radius: inherit;
background: var(--v2-icon-icon-base);
transition: width 160ms ease-out;
}
[data-slot="upload-row-cancel"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
svg {
width: 12px;
height: 12px;
}
}
}
[data-component="composer-attachments"] {
timeline-scope: --composer-attachments-scroll;
@@ -1,12 +1,8 @@
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createBlobReference } from "@/runtime/persistence/drafts"
import { uuid } from "@/runtime/persistence/uuid"
import type { ComposerPrompt } from "../types"
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
import type { AttachmentDestination } from "./destination"
import { uploads } from "./uploads"
import type { ComposerAttachment, ComposerPrompt } from "../types"
type PromptTarget = {
current: () => ComposerPrompt
@@ -20,11 +16,9 @@ export type ComposerAttachmentConfig = {
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
directory: () => string
destination: () => AttachmentDestination
isDialogActive: () => boolean
duplicate: () => void
onError: (error: unknown) => void
onUploadError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
@@ -49,23 +43,9 @@ export function createComposerAttachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
// store. Everything else, including text, reaches the model as a path on the server that its
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
const add = async (file: File, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
const destination = input.destination()
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
const sourcePath = input.getPathForFile?.(file) || undefined
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
void stage(file, mime, target, destination)
return true
}
const addInline = async (file: File, mime: string, target: NonNullable<ReturnType<typeof capture>>, clipboard: boolean) => {
const blob = input.store ? await input.store(file) : await createBlobReference(file)
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
@@ -84,40 +64,17 @@ export function createComposerAttachments(
input.duplicate()
return true
}
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
const attachment: ComposerAttachment = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath,
mime,
blob,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addPath = (
target: NonNullable<ReturnType<typeof capture>>,
attachment: Pick<PathAttachmentPart, "filename" | "mime" | "path">,
) => {
if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) {
input.duplicate()
return true
}
target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor())
return true
}
const stage = async (
file: File,
mime: string,
target: NonNullable<ReturnType<typeof capture>>,
destination: AttachmentDestination,
) => {
const id = uuid()
setPending("ids", (ids) => [...ids, id])
const path = await uploads
.track({ id, filename: file.name, mime, size: file.size }, (report, signal) =>
destination.upload(file, report, signal),
)
.catch((error: unknown) => {
input.onUploadError(error)
return undefined
})
.finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id)))
if (path) addPath(target, { filename: file.name, mime, path })
}
const addAttachments = async (files: File[], target = capture()) => {
return files.reduce(async (result, file) => {
const previous = await result
@@ -196,11 +153,6 @@ export function createComposerAttachments(
addAttachments,
handlePaste,
handleDrop,
/** Uploads still in flight for this composer; sending waits for them. */
pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)),
cancel(id: string) {
uploads.items().find((item) => item.id === id)?.cancel()
},
pick(fallback: () => void) {
if (!input.picker) {
fallback()
@@ -213,16 +165,6 @@ export function createComposerAttachments(
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// The server rejects inline attachments above this size, so larger media takes the path route.
const MAX_INLINE_BYTES = 20 * 1024 * 1024
// Mirrors the media the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
const imageExtensions = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
@@ -240,8 +182,8 @@ const textMimes = new Set([
"application/yaml",
])
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
// a binary type. Delivery is decided separately: native media inline, everything else by path.
// Text-like files normalize to text/plain so the server inlines their content; every other
// file keeps a binary type and is delivered to the model by path or as native media.
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
@@ -0,0 +1,70 @@
import type { Accessor } from "solid-js"
import { blobBytes, blobDataUrl } from "@/runtime/persistence/drafts"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
import type { ImageAttachmentPart } from "../state"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Copies a file into the server's temporary directory and returns its absolute path there. */
upload: (file: { name: string; data: Uint8Array }) => Promise<string>
}
export type DeliveredAttachment =
| { type: "inline"; attachment: ImageAttachmentPart; dataUrl: string }
| { type: "path"; attachment: ImageAttachmentPart; path: string }
// An attachment travels inline when the model reads its bytes natively. Anything else reaches
// the model as a path on the server, which its tools can open, instead of being rejected.
export function deliverAttachments(attachments: ImageAttachmentPart[], destination: AttachmentDestination) {
return Promise.all(attachments.map((attachment) => deliver(attachment, destination)))
}
async function deliver(
attachment: ImageAttachmentPart,
destination: AttachmentDestination,
): Promise<DeliveredAttachment> {
if (native(attachment.mime, destination.input)) {
return { type: "inline", attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) }
}
if (destination.local && attachment.sourcePath) return { type: "path", attachment, path: attachment.sourcePath }
const path = await destination.upload({ name: attachment.filename, data: await blobBytes(attachment.blob) })
return { type: "path", attachment, path }
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file) => {
const info = await sdk.api.server.info()
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const written = await sdk.api.file.write({
location: { directory: location().directory },
path: `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`,
payload: file.data,
})
return written.data.path
},
})
}
@@ -1,57 +0,0 @@
import type { Accessor } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { authTokenFromCredentials } from "@/runtime/server/api"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Streams a file into the server's temporary directory and returns its absolute path there. */
upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise<string>
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file, report, signal) => {
const info = await sdk.api.server.info({ signal })
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
url.searchParams.set("location[directory]", location().directory)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
return write(url, file, server.conn.http.password, report, signal)
},
})
}
// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so
// the one request that needs both goes through XMLHttpRequest. The browser streams the File
// from disk; nothing is buffered in the renderer.
function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) {
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", url)
xhr.responseType = "json"
xhr.setRequestHeader("content-type", "application/octet-stream")
if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`)
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
xhr.addEventListener("load", () => {
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
resolve((xhr.response as { data: { path: string } }).data.path)
})
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")))
signal.addEventListener("abort", () => xhr.abort(), { once: true })
xhr.send(file)
})
}
@@ -1,116 +0,0 @@
import { createEffect, createRoot, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { Toast, toaster } from "@opencode/ui/toast"
import { useLanguage } from "@/runtime/i18n/language"
export type Upload = {
id: string
filename: string
mime: string
size: number
loaded: number
cancel: () => void
}
// Uploads outlive the composer that started them, so one process-wide list feeds every chip
// and the single progress toast.
const [state, setState] = createStore<{ items: Upload[] }>({ items: [] })
export const uploads = {
items: () => state.items,
/** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */
async track<T>(
input: Pick<Upload, "id" | "filename" | "mime" | "size">,
work: (report: (loaded: number) => void, signal: AbortSignal) => Promise<T>,
): Promise<T | undefined> {
const controller = new AbortController()
setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }])
try {
return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal)
} catch (error) {
if (controller.signal.aborted) return undefined
throw error
} finally {
setState("items", (items) => items.filter((item) => item.id !== input.id))
}
},
}
// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host
// lives inside the providers, lends the toast its language instance, and gives the content a
// root of its own so progress stays reactive.
export function UploadToastHost() {
const language = useLanguage()
let active: { id: number; dispose: () => void } | undefined
const dismiss = () => {
if (!active) return
toaster.dismiss(active.id)
active.dispose()
active = undefined
}
createEffect(
on(
() => state.items.length > 0,
(uploading) => {
if (!uploading) return dismiss()
if (active) return
const id = toaster.show(
(props) =>
createRoot((dispose) => {
active = { id: props.toastId, dispose }
return <UploadToast toastId={props.toastId} language={language} />
}),
{ persistent: true, resize: () => state.items.length },
)
active ??= { id, dispose: () => {} }
},
),
)
onCleanup(dismiss)
return null
}
function UploadToast(props: { toastId: number; language: ReturnType<typeof useLanguage> }) {
const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100))
return (
<Toast toastId={props.toastId}>
<Toast.Content>
<For each={state.items}>
{(item) => (
<div data-component="upload-row">
<div data-slot="upload-row-label">
<span data-slot="upload-row-name" title={item.filename}>
{item.filename}
</span>
<span data-slot="upload-row-percent">
{props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })}
</span>
</div>
<div data-slot="upload-row-track">
<div
data-component="upload-progress"
role="progressbar"
aria-label={item.filename}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent(item)}
>
<div data-slot="upload-progress-bar" style={{ width: `${percent(item)}%` }} />
</div>
<button
type="button"
data-slot="upload-row-cancel"
aria-label={props.language.t("prompt.toast.uploading.cancel")}
onClick={() => item.cancel()}
>
<Icon name="outline-xmark" />
</button>
</div>
</div>
)}
</For>
</Toast.Content>
</Toast>
)
}
@@ -167,7 +167,7 @@ function ComposerStory(props: {
? buildPromptRequest({
prompt: draft.prompt,
context: draft.context.items,
images: [],
attachments: [],
text: value,
sessionDirectory: "C:/repo",
})
+6 -6
View File
@@ -7,7 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { isAttachment, promptLength } from "../prompt-parts"
import { promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()((state) => ({
prompt: [
{ type: "text", content, start: 0, end: content.length },
...state.prompt.filter(isAttachment),
...state.prompt.filter((part) => part.type === "image"),
],
cursor: content.length,
retry: undefined,
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
clearRetry()
},
removeAttachment(id: string) {
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
clearRetry()
},
}
@@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co
let position = 0
let inserted = false
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (isAttachment(part)) return [part]
if (part.type === "image") return [part]
const start = position
position += part.content.length
if (inserted) return [part]
@@ -121,7 +121,7 @@ function insertMention(
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (isAttachment(part)) return [part]
if (part.type === "image") return [part]
const partStart = position
position += part.content.length
if (part.type !== "text" || start < partStart || end > position) return [part]
@@ -139,7 +139,7 @@ function insertMention(
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
let offset = 0
return prompt.map((part) => {
if (isAttachment(part)) return part
if (part.type === "image") return part
const next = { ...part, start: offset, end: offset + part.content.length }
offset = next.end
return next
+7 -47
View File
@@ -12,8 +12,6 @@ import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { ScrollView } from "@opencode/ui/scroll-view"
import { AttachmentCard } from "@opencode/session-ui/attachment-card"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
@@ -26,7 +24,6 @@ import type {
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
@@ -151,13 +148,11 @@ export function ComposerEditor(props: ComposerEditorProps) {
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
/>
@@ -196,9 +191,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const attachments = props.controller.parts().filter(isAttachment)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
@@ -353,7 +348,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (isAttachment(part)) return []
if (part.type === "image") return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
@@ -480,22 +475,17 @@ function composerCursor(editor: HTMLDivElement) {
export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
comments?: ComposerComment[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
onAttachmentRemove: (attachment: ComposerAttachment) => void
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
>
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -532,30 +522,22 @@ export function ComposerAttachments(props: {
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<Show
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
when={attachment.mime.startsWith("image/")}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
{(image) => (
<>
<img
src={image().blob.url}
src={attachment.blob.url}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)}
</Show>
</Tooltip>
<button
@@ -569,28 +551,6 @@ export function ComposerAttachments(props: {
</div>
)}
</For>
<For each={props.uploads ?? []}>
{(upload) => (
<div class="relative group shrink-0" data-slot="composer-upload">
<Tooltip value={upload.filename} placement="top" contentClass="break-all">
<AttachmentCard title={upload.filename}>
<span class="inline-flex items-center gap-1">
<ProgressCircle percentage={percent(upload)} />
{i18n.t("ui.promptInput.uploading", { percent: percent(upload) })}
</span>
</AttachmentCard>
</Tooltip>
<button
type="button"
onClick={() => props.onUploadCancel?.(upload)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={i18n.t("ui.promptInput.cancelUpload")}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</div>
<div
data-slot="composer-attachments-fade-left"
@@ -2,7 +2,6 @@ import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
import type { Upload } from "../attachments/uploads"
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
import type {
ComposerAttachment,
@@ -19,7 +18,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
@@ -75,7 +74,7 @@ export function createComposerEditor(input: {
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (isAttachment(part)) return false
if (part.type === "image") return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
@@ -170,7 +169,7 @@ export function createComposerEditor(input: {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter(isAttachment),
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
0,
)
}
@@ -316,13 +315,7 @@ export function createComposerEditor(input: {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter(isAttachment)
},
uploads(): Upload[] {
return attachments?.pending() ?? []
},
cancelUpload(id: string) {
attachments?.cancel(id)
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
},
toggleContext(id: string) {
dispatch({ type: "context.active", id })
@@ -343,12 +336,11 @@ export function createComposerEditor(input: {
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
if (attachments?.pending().length) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some(isAttachment)) return true
if (persisted.prompt.some((part) => part.type === "image")) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
@@ -377,7 +369,6 @@ export function createComposerEditor(input: {
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
if (attachments?.pending().length) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt, isAttachment } from "../prompt-parts"
import { clonePrompt } from "../prompt-parts"
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
@@ -35,9 +35,9 @@ export function prependHistoryEntry(
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasAttachments = prompt.some(isAttachment)
const hasImages = prompt.some((part) => part.type === "image")
const hasComments = comments.some((comment) => !!comment.comment.trim())
if (!text && !hasAttachments && !hasComments) return entries
if (!text && !hasImages && !hasComments) return entries
const entry = {
prompt: clonePrompt(prompt),
@@ -86,7 +86,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt
if (partA.type === "skill") {
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
}
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
+6 -14
View File
@@ -17,12 +17,12 @@ import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode/schema/skill"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import { isAttachment } from "./prompt-parts"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/destination"
import { useAttachmentDestination } from "./attachments/deliver"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -73,7 +73,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}, [])
})
const attachments = createMemo(() =>
prompt.current().filter(isAttachment),
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
@@ -266,6 +266,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
destination: useAttachmentDestination(adapter.controls),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
@@ -320,10 +321,8 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
},
openAttachment: (attachment) =>
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, adapter.controls(), layout, files, comments)
@@ -341,15 +340,8 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
destination: useAttachmentDestination(adapter.controls),
isDialogActive: () => !!dialog.active,
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onUploadError: (error) =>
showToast({
variant: "error",
title: language.t("prompt.toast.uploadFailed.title"),
description: composerErrorMessage(language, error),
}),
onError: (error) =>
showToast({
variant: "error",
+2 -7
View File
@@ -1,9 +1,4 @@
import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state"
/** Parts that sit beside the text rather than inside it. */
export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart {
return part.type === "image" || part.type === "path"
}
import type { Prompt } from "./state"
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
@@ -22,7 +17,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}
+22 -17
View File
@@ -1,10 +1,15 @@
import { describe, expect, test } from "bun:test"
import { Skill } from "@opencode/schema/skill"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import type { DeliveredAttachment } from "./attachments/deliver"
import { buildPromptRequest } from "./request"
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>) {
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>): DeliveredAttachment {
return {
type: "inline",
attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra },
dataUrl: `data:${mime};base64,AAA`,
}
}
describe("buildPromptRequest", () => {
@@ -25,7 +30,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [inline("a.png", "image/png")],
attachments: [inline("a.png", "image/png")],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -47,7 +52,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
text: "check these",
sessionDirectory: "/repo",
})
@@ -62,7 +67,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
attachments: [
inline("opencode.global.dat", "text/plain", {
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
}),
@@ -90,7 +95,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
@@ -112,7 +117,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
attachments: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
@@ -134,7 +139,7 @@ describe("buildPromptRequest", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
attachments: [],
text: "look",
sessionDirectory: "/repo",
})
@@ -150,7 +155,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
@@ -171,7 +176,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
@@ -192,7 +197,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
@@ -206,7 +211,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
@@ -221,7 +226,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
attachments: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
@@ -243,7 +248,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
@@ -270,7 +275,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
@@ -295,7 +300,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
@@ -325,7 +330,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@review",
sessionDirectory: "/repo",
})
+11 -11
View File
@@ -1,13 +1,14 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import {
formatAttachmentReference,
formatCommentNote,
type PromptAttachmentReference,
type PromptComment,
} from "@/composer/comment-note"
import type { DeliveredAttachment } from "@/composer/attachments/deliver"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
@@ -34,7 +35,7 @@ type ContextFile = {
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
attachments: DeliveredAttachment[]
text: string
sessionDirectory: string
}
@@ -62,7 +63,6 @@ const parseCommentMentions = (comment: string) => {
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
@@ -113,15 +113,15 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
return [file, ...mentions]
})
const inline = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
const inline = input.attachments.flatMap((item) =>
item.type === "inline"
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
: [],
)
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.prompt
.filter(isPathAttachment)
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
const attachments = input.attachments.flatMap((item) =>
item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [],
)
return {
text: [
+1 -18
View File
@@ -94,24 +94,7 @@ export const ImageAttachmentPart = Schema.Struct({
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
// A file the model receives as a path on the server: its bytes never enter the draft store.
export const PathAttachmentPart = Persistence.struct({
type: Schema.Literal("path"),
id: Schema.String,
filename: Schema.String,
mime: Schema.String,
path: Schema.String,
})
export type PathAttachmentPart = typeof PathAttachmentPart.Type
export const ContentPart = Schema.Union([
TextPart,
FileAttachmentPart,
AgentPart,
SkillPart,
ImageAttachmentPart,
PathAttachmentPart,
])
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
-1
View File
@@ -23,7 +23,6 @@ export type {
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
PromptModel,
SkillPart,
@@ -1,5 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
import { appendPrompt, clonePrompt } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@@ -22,7 +22,7 @@ export function createComposerSubmission(input: {
if (initial !== target) {
initial.reset()
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
+10
View File
@@ -3,6 +3,7 @@ import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import type { AttachmentDestination } from "./attachments/deliver"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -48,6 +49,14 @@ function controls(): ComposerControls {
}
}
const destination: AttachmentDestination = {
input: { image: true, pdf: true },
local: false,
upload: async () => {
throw new Error("native attachments must not upload")
},
}
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
@@ -64,6 +73,7 @@ function submitInput(
resetHistory() {},
setMode() {},
closePopover() {},
destination: () => destination,
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
+30 -20
View File
@@ -8,8 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -35,6 +34,7 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
destination: () => AttachmentDestination
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
@@ -87,10 +87,16 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
const sending = sendPrompt(
session,
value,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
() => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
},
).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -123,9 +129,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
value,
command,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -152,9 +162,6 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
})),
metadata: {
displayText: value.text,
attachments: value.prompt.flatMap((part) =>
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
),
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
@@ -189,7 +196,7 @@ function readSubmission(
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
if (!text.trim() && images.length === 0 && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
@@ -297,9 +304,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
destination: AttachmentDestination,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
@@ -338,10 +346,11 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
@@ -375,14 +384,15 @@ async function sendPrompt(
await sending
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
)
async function buildSubmissionRequest(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
) {
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
images,
attachments: await deliverAttachments(value.images, destination),
text: value.text,
sessionDirectory: session.directory,
})
@@ -1,5 +1,4 @@
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
import { isAttachment } from "../prompt-parts"
export type ComposerInteractionState = {
mode: "normal" | "shell"
@@ -237,7 +236,7 @@ function populated(persisted: ComposerPersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
)
}
+2 -10
View File
@@ -1,17 +1,9 @@
import type {
AgentPart,
ComposerStore,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "./state"
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
export type ComposerFilePart = FileAttachmentPart
export type ComposerAgentPart = AgentPart
export type ComposerSkillPart = SkillPart
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
export type ComposerAttachment = ImageAttachmentPart
export type ComposerPrompt = Prompt
export type ComposerComment = ComposerStore["context"]["items"][number]
export type ComposerPersistedState = ComposerStore
@@ -48,8 +48,7 @@ export function createNewSessionComposerAdapter(props: {
submitted: props.submitted,
async start(selection, submission, message) {
const draftID = props.draftID
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
-51
View File
@@ -74,7 +74,6 @@ export const dict = {
"command.category.workspace": "Worktree",
"command.category.settings": "Settings",
"command.logs.export": "Export logs",
"command.debugBar.toggle": "Toggle debug bar",
"theme.scheme.system": "System",
"theme.scheme.light": "Light",
@@ -367,9 +366,6 @@ export const dict = {
"prompt.action.stop": "Stop",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.uploading.percent": "{{percent}}%",
"prompt.toast.uploading.cancel": "Cancel upload",
"prompt.toast.uploadFailed.title": "Upload failed",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
@@ -440,44 +436,6 @@ export const dict = {
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"server.connect.local.title": "This server is on a local network",
"server.connect.local.description":
"This page can't reach HTTP servers on your local network. Open the server's own web interface to continue on this device.",
"server.connect.local.open": "Open on local network",
"server.connect.local.loopback.title": "This server only listens on localhost",
"server.connect.local.loopback.description":
"Other devices can't reach a server that only listens on localhost. Opening it will only work on the computer running OpenCode.",
"server.connect.local.loopback.open": "Open on this computer",
"server.connect.local.loopback.fix": "Run this command on your computer, then pair again.",
"command.server.pair": "Pair device",
"settings.pairing.title": "Pairing",
"settings.pairing.connection": "Local Network",
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
"pair.local.open": "Show details",
"pair.screenActive.title": "Keep screen active",
"pair.screenActive.description": "Prevent this computers display from sleeping while OpenCode is running.",
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
"pair.qr.open": "Show details",
"pair.tailscale.enable": "Enable Tailscale",
"pair.tailscale.serve": "Tailscale Serve",
"pair.description": "Connect another device to this machine's OpenCode server.",
"pair.loading": "Loading connection details…",
"pair.qr": "Pairing QR code",
"pair.copy": "Copy details",
"pair.copy.error": "Could not copy pairing details. Try again.",
"pair.urls": "URLs",
"pair.username": "Username",
"pair.password": "Password",
"pair.error": "Could not update pairing details. Try again.",
"pair.tailscale.open": "Share with Tailscale",
"pair.tailscale.opening": "Enabling Tailscale Serve…",
"pair.tailscale.title": "Tailscale",
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
"pair.tailscale.checking": "Checking Tailscale Serve…",
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
"pair.tailscale.disable": "Disable",
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -1005,16 +963,7 @@ export const dict = {
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.providerAriaLabel": "Provider performance diagnostics",
"debugBar.na": "n/a",
"debugBar.ttft.label": "TTFT",
"debugBar.ttft.tip": "Time from provider request dispatch to the first model output.",
"debugBar.ttfa.label": "TTFA",
"debugBar.ttfa.tip": "Time from provider request dispatch to the first answer text.",
"debugBar.tps.label": "TPS",
"debugBar.tps.tip": "Output tokens per second after the first model output.",
"debugBar.e2e.label": "E2E",
"debugBar.e2e.tip": "Time from provider request dispatch until its response stream ended.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
+10 -2
View File
@@ -421,9 +421,17 @@ function referenced(json: string) {
return ids
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
async function blobData(blob: BlobReference) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
}
export async function blobBytes(blob: BlobReference) {
return new Uint8Array(await (await blobData(blob)).arrayBuffer())
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await blobData(blob)
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -22,12 +22,6 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
error: string
url: string
@@ -107,10 +101,6 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -134,15 +124,6 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
tailscaleAvailable(): Promise<boolean>
tailscaleStatus(): Promise<PairingInfo | null>
openTailscale(): Promise<PairingInfo>
disableTailscale(): Promise<void>
}
}
export type Platform = PlatformBase &
+1 -5
View File
@@ -7,10 +7,6 @@ export function isMixedContent(page: string, address: string) {
const url = new URL(normalized)
if (url.protocol !== "http:") return false
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
return !isLoopback(url)
}
export function isLoopback(url: URL) {
const host = url.hostname.replace(/\.$/, "")
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host)
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
}
@@ -1,23 +0,0 @@
import { createResource } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
export function createCameraAvailability() {
const platform = usePlatform()
const supported = platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
const [available, actions] = createResource(
async () => {
if (!supported || !navigator.mediaDevices.enumerateDevices) return false
const denied = await navigator.permissions?.query({ name: "camera" }).then(
(permission) => permission.state === "denied",
() => false,
)
if (denied) return false
return navigator.mediaDevices.enumerateDevices().then(
(devices) => devices.some((device) => device.kind === "videoinput"),
() => false,
)
},
{ initialValue: false },
)
return { supported, available, refetch: actions.refetch }
}
+56 -141
View File
@@ -4,17 +4,7 @@ import { Divider } from "@opencode/ui/divider"
import { TextInput } from "@opencode/ui/text-input"
import { useDialog } from "@opencode/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
type Component,
Show,
Suspense,
createEffect,
createMemo,
createSignal,
lazy,
onCleanup,
onMount,
} from "solid-js"
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerHealthPreview,
@@ -28,12 +18,8 @@ import { useTabs } from "@/shell/tabs/tabs"
import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import { decodePairingCode } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
@@ -43,8 +29,6 @@ export const DialogServer: Component<{
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const camera = createCameraAvailability()
const form = createFormController({
onSelect: (server) => {
props.onSave?.(server)
@@ -91,112 +75,64 @@ export const DialogServer: Component<{
</DialogHeader>
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<Show
when={!form.state.scanning()}
fallback={
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
<PairingScanner
onCancel={() => {
form.scan.stop()
void camera.refetch()
}}
onScan={form.scan.complete}
/>
</Suspense>
}
>
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
list="dialog-server-addresses"
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<datalist id="dialog-server-addresses">
{form.state.urls().map((url) => (
<option value={url} />
))}
</datalist>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInput
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<Show when={props.mode === "add" && platform.platform === "web"}>
<div class="flex w-full min-w-0 flex-col gap-2">
<Button
variant="neutral"
size="large"
class="!w-full self-stretch"
disabled={form.state.busy() || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading
? "dialog-server-camera-unavailable"
: undefined
}
onClick={form.scan.start}
>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.available.latest && !camera.available.loading}>
<span id="dialog-server-camera-unavailable" class="settings-server-dialog-hint">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
)}
</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
</Show>
</div>
</Show>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInput
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
</div>
</DialogBody>
<Show when={!form.state.scanning()}>
<DialogFooter>
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()}
</Button>
</DialogFooter>
</Show>
<DialogFooter>
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()}
</Button>
</DialogFooter>
</Dialog>
)
}
@@ -213,8 +149,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", password: "" },
urls: [] as string[],
scanning: false,
error: "",
status: undefined as boolean | undefined,
})
@@ -227,8 +161,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
})
@@ -333,16 +265,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
setStore("error", "")
request.mutate()
}
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
healthPreview.cancel()
setStore({
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
urls: pairing.urls,
scanning: false,
error: "",
})
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
@@ -359,8 +281,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
value: () => store.values.url,
name: () => store.values.name,
password: () => store.values.password,
urls: () => store.urls,
scanning: () => store.scanning,
error: () => store.error,
status: () => store.status,
},
@@ -369,11 +289,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
name: (value: string) => change("name", value),
password: (value: string) => change("password", value),
},
scan: {
start: () => setStore("scanning", true),
stop: () => setStore("scanning", false),
complete: pair,
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
@@ -1,86 +0,0 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://computer.tailnet.ts.net:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
const info = {
urls: ["http://192.168.1.2:4096"],
username: "opencode" as const,
password: "a+b & café",
}
const url = new URL(pairingUrl(info, "https://example.com"))
expect(url.origin).toBe("https://example.com")
expect(url.pathname).toBe("/connect")
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
expect(decodePairingUrl(url.search)).toEqual({
urls: ["http://192.168.1.2:4096"],
password: "a+b & café",
})
})
test("defaults to the hosted app for desktop pairing", () => {
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
"https://app.opencode.ai",
)
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
const decoded = { urls: info.urls, password: info.password }
test("decodes raw JSON, desktop query links, and the CLI fragment link", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual(decoded)
expect(decodePairingScan(pairingUrl(info, "http://192.168.1.2:49374"))).toEqual(decoded)
const encoded = Buffer.from(JSON.stringify(info)).toString("base64url")
expect(decodePairingScan(`https://app.opencode.ai/connect#${encoded}`)).toEqual(decoded)
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
+3 -39
View File
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.optional(Schema.Array(Schema.String)),
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
@@ -19,46 +19,10 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string, origin?: string) {
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [
...new Set(
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
),
]
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(
value: { urls?: readonly string[]; username: "opencode"; password: string },
host = "https://app.opencode.ai",
) {
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
}
export function decodePairingScan(value: string) {
const url = URL.parse(value)
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash)
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
}
@@ -1,41 +0,0 @@
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
p {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
button {
min-height: 44px;
width: 100%;
}
.server-connect-error {
color: var(--v2-state-fg-danger);
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
+3 -4
View File
@@ -3,11 +3,10 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingScan } from "./pairing"
import "./scanner.css"
import { decodePairingCode } from "./pairing"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onCancel: () => void
}) {
const language = useLanguage()
@@ -22,7 +21,7 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingScan(result.data)
const pairing = decodePairingCode(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
+23 -1
View File
@@ -41,7 +41,8 @@
color: var(--v2-text-text-muted);
}
form {
form,
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
@@ -94,4 +95,25 @@
background: var(--v2-background-bg-layer-01);
user-select: all;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
+25 -56
View File
@@ -1,4 +1,4 @@
import { lazy, Show, Suspense } from "solid-js"
import { createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
@@ -9,28 +9,34 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { isLoopback, isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import { isMixedContent } from "./browser"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
export function ConnectServerScreen() {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const cameraSupported =
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
const [camera, cameraActions] = createResource(
async () => {
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
const denied = await navigator.permissions?.query({ name: "camera" }).then(
(permission) => permission.state === "denied",
() => false,
)
if (denied) return false
return navigator.mediaDevices.enumerateDevices().then(
(devices) => devices.some((device) => device.kind === "videoinput"),
() => false,
)
},
{ initialValue: false },
)
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -51,7 +57,6 @@ export function ConnectServerScreen(
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -73,7 +78,7 @@ export function ConnectServerScreen(
<PairingScanner
onCancel={() => {
setState("scanning", false)
void camera.refetch()
void cameraActions.refetch()
}}
onScan={(pairing) => {
setState({
@@ -149,15 +154,13 @@ export function ConnectServerScreen(
<Button
variant="neutral"
size="large"
disabled={request.isPending || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
}
disabled={request.isPending || !camera.latest}
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.available.latest && !camera.available.loading}>
<Show when={!camera.latest && !camera.loading}>
<p id="server-connect-camera-unavailable">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
@@ -174,37 +177,3 @@ export function ConnectServerScreen(
</main>
)
}
export function ConnectLocalScreen(props: { urls: readonly string[] }) {
const language = useLanguage()
const target = props.urls[0]
const loopback = isLoopback(new URL(target))
return (
<main data-component="connect-server" aria-labelledby="server-connect-title">
<div class="server-connect-content">
<div class="server-connect-brand" role="img" aria-label="OpenCode">
<Wordmark />
</div>
<header>
<h1 id="server-connect-title">
{language.t(loopback ? "server.connect.local.loopback.title" : "server.connect.local.title")}
</h1>
<p>{language.t(loopback ? "server.connect.local.loopback.description" : "server.connect.local.description")}</p>
</header>
<Button
variant="contrast"
size="large"
onClick={() => location.assign(`${new URL("/connect", target)}${location.search}${location.hash}`)}
>
{language.t(loopback ? "server.connect.local.loopback.open" : "server.connect.local.open")}
</Button>
<footer>
<Show when={loopback}>
<p>{language.t("server.connect.local.loopback.fix")}</p>
</Show>
<code dir="ltr">{loopback ? "opencode service set hostname 0.0.0.0" : target}</code>
</footer>
</div>
</main>
)
}
@@ -2,6 +2,7 @@ import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useAttachmentDestination } from "@/composer/attachments/deliver"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
@@ -29,6 +30,7 @@ export function createSessionComposerController(input: {
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
destination: useAttachmentDestination(input.controls),
restoreFocus: (cursor) => {
const target = editor
if (!target) return
+28 -40
View File
@@ -5,11 +5,11 @@ import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -30,6 +30,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
destination: () => AttachmentDestination
restoreFocus: (cursor: number) => void
}) {
const data = useData()
@@ -60,6 +61,7 @@ export function createSessionQueue(input: {
change.item,
change.prompt,
change.text,
input.destination(),
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
@@ -185,15 +187,15 @@ export function createSessionQueue(input: {
if (!editing || mutation.isPending) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const attachments = prompt.filter(isAttachment)
if (!text.trim() && !attachments.length) return cancelEdit()
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
if (!text.trim() && !images.length) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const original = item ? queuedPromptAttachments(item) : []
const pristine =
item &&
text.trim() === queuedPromptText(item) &&
attachments.length === original.length &&
attachments.every((attachment, index) => attachment.id === original[index].id)
images.length === original.length &&
images.every((image, index) => image.id === original[index].id)
if (pristine && delivery === "queue") return cancelEdit()
mutation.mutate({
type: "edit",
@@ -249,7 +251,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments: item.payload.files?.length ?? 0,
}))
}
@@ -259,32 +261,18 @@ export function queuedPromptText(item: QueuedPrompt) {
}
// Inline attachments are the files the composer added itself, so they return
// to it as image parts that an edit can remove or extend, and path references
// return as path parts. Mentions and `file://` context stay in the payload; see
// editedPromptInput.
export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPart | PathAttachmentPart)[] {
return [
...(item.payload.files ?? [])
.filter((file) => isComposerAttachment(file))
.map(
(file, index): ImageAttachmentPart => ({
type: "image",
id: `${item.id}:file:${index}`,
filename: file.name ?? "attachment",
mime: file.mime,
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
}),
),
...(readPromptPresentation(item.payload.metadata)?.attachments ?? []).map(
(file, index): PathAttachmentPart => ({
type: "path",
id: `${item.id}:path:${index}`,
filename: file.name,
mime: file.mime,
path: file.path,
}),
),
]
// to it as image parts that an edit can remove or extend. Mentions and
// `file://` context stay in the payload; see editedPromptInput.
export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] {
return (item.payload.files ?? [])
.filter((file) => isComposerAttachment(file))
.map((file, index) => ({
type: "image",
id: `${item.id}:file:${index}`,
filename: file.name ?? "attachment",
mime: file.mime,
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
}))
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
@@ -304,13 +292,13 @@ async function editedPromptInput(
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
destination: AttachmentDestination,
) {
const images = await Promise.all(
prompt
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
const attachments = await deliverAttachments(
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
destination,
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
-2
View File
@@ -7,7 +7,6 @@ export const pageIcons = {
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
pairing: "server",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
@@ -23,7 +22,6 @@ export const pageLabels = {
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
pairing: "settings.pairing.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
@@ -1,303 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
export function SettingsPairing() {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const queryClient = useQueryClient()
const pair = platform.pair
if (!pair) return null
const local = useQuery(() => ({
queryKey: ["pairing", "local"],
queryFn: pair.info,
}))
// Reading pending query data would suspend the entire settings surface.
const localInfo = () => (local.isSuccess ? local.data : undefined)
const localHost = createMemo(() =>
localInfo()?.urls.find((value) => {
const host = new URL(value).hostname
return (
host !== "localhost" &&
!host.endsWith(".localhost") &&
!host.startsWith("127.") &&
host !== "[::1]" &&
host !== "0.0.0.0" &&
host !== "[::]"
)
}),
)
const screenActive = useQuery(() => ({
queryKey: ["pairing", "screen-active"],
queryFn: () => platform.getKeepScreenActive!(),
enabled: !!platform.getKeepScreenActive,
}))
const screenActivity = useMutation(() => ({
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
}))
const tailscale = useQuery(() => ({
queryKey: ["pairing", "tailscale-available"],
queryFn: pair.tailscaleAvailable,
}))
const tailscaleStatus = useQuery(() => ({
queryKey: ["pairing", "tailscale-status"],
queryFn: pair.tailscaleStatus,
enabled: tailscale.isSuccess && tailscale.data === true,
}))
const tailscaleServe = useMutation(() => ({
mutationFn: pair.openTailscale,
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
}))
const tailscaleDisable = useMutation(() => ({
mutationFn: pair.disableTailscale,
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
}))
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
<SettingsList>
<SettingsRow
title={language.t("settings.pairing.connection")}
description={language.t("pair.local.description")}
>
<Button
variant="neutral"
disabled={!localHost()}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()}
/>
))
}
>
{language.t("pair.local.open")}
</Button>
</SettingsRow>
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
<div data-action="settings-keep-screen-active">
<SettingsRow
title={language.t("pair.screenActive.title")}
description={language.t("pair.screenActive.description")}
>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{language.t("pair.screenActive.title")}
</Switch>
</SettingsRow>
</div>
</Show>
</SettingsList>
<Show when={screenActive.error || screenActivity.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.screenActive.error")}
</p>
</Show>
<Show when={local.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
</section>
<Show when={tailscale.isSuccess && tailscale.data === true}>
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
<Show
when={tailscaleInfo()}
fallback={
<>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
<Button
variant="neutral"
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
aria-busy={tailscaleServe.isPending}
onClick={() => tailscaleServe.mutate()}
>
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<g opacity="0.3">
<circle cx="3" cy="3" r="3" />
<circle cx="12" cy="3" r="3" />
<circle cx="21" cy="3" r="3" />
<circle cx="3" cy="21" r="3" />
<circle cx="21" cy="21" r="3" />
</g>
<circle cx="3" cy="12" r="3" />
<circle cx="12" cy="12" r="3" />
<circle cx="21" cy="12" r="3" />
<circle cx="12" cy="21" r="3" />
</svg>
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
</Button>
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
{language.t("pair.tailscale.description")}
</span>
</div>
</SettingsList>
</>
}
>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<SettingsRow
title={language.t("pair.tailscale.serve")}
description={language.t("pair.tailscale.description")}
>
<div class="flex flex-col items-end gap-2">
<Button
variant="neutral"
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("pair.tailscale.title")}
info={tailscaleInfo()}
host={tailscaleInfo()?.urls[0]}
/>
))
}
>
{language.t("pair.qr.open")}
</Button>
<Button
variant="neutral"
disabled={
!localInfo() ||
tailscaleStatus.isPending ||
tailscaleServe.isPending ||
tailscaleDisable.isPending
}
onClick={() => tailscaleDisable.mutate()}
>
{language.t("pair.tailscale.disable")}
</Button>
</div>
</SettingsRow>
</SettingsList>
</Show>
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.tailscale.error")}
</p>
</Show>
</section>
</Show>
</div>
</>
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
const language = useLanguage()
const platform = usePlatform()
const url = createMemo(() => {
if (!props.info) return
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
return pairingUrl(
{
urls: host ? [host] : props.info.urls.slice(0, 1),
username: props.info.username,
password: props.info.password,
},
host,
)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
const value = url()
if (!value) return
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
},
}))
createEffect(() => {
if (!copy.isSuccess) return
const timeout = setTimeout(() => copy.reset(), 2000)
onCleanup(() => clearTimeout(timeout))
})
const qr = createMemo(() => {
const value = url()
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
})
return (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
aria-label={language.t("pair.qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 justify-center pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={language.t("pair.copy")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
</p>
</Show>
</DialogBody>
</Dialog>
)
}
@@ -20,20 +20,6 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{
tab: "pairing",
label: "settings.pairing.title",
keywords: "pair device qr tailscale",
available: "desktop",
},
{
tab: "pairing",
label: "pair.screenActive.title",
description: "pair.screenActive.description",
target: "settings-keep-screen-active",
keywords: "display sleep awake local",
available: "desktop",
},
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
-6
View File
@@ -1517,12 +1517,6 @@
color: var(--v2-state-fg-danger);
}
.settings-server-dialog-hint {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: min(280px, 100%);
}
+1 -12
View File
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
@@ -19,7 +18,6 @@ import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsPairing } from "./pairing/pairing"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
@@ -43,7 +41,6 @@ const rootClientTabs = [
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
] as const
const serverTabs = [
@@ -190,7 +187,6 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
@@ -220,11 +216,7 @@ function RootSettings() {
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: rootClientTabs
.filter((item) => item.value !== "pairing" || !!platform.pair)
.map((item) => ({ ...item, label: language.t(item.label) })),
},
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
...(multiple()
? [
{
@@ -290,9 +282,6 @@ function RootSettings() {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="pairing" class="settings-panel">
<SettingsPairing />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
-2
View File
@@ -11,7 +11,6 @@ export type SettingsRootTab =
| "appearance"
| "notifications"
| "shortcuts"
| "pairing"
| "projects"
| "workspaces"
| "providers"
@@ -45,7 +44,6 @@ const rootTabs: Record<SettingsRootTab, true> = {
appearance: true,
notifications: true,
shortcuts: true,
pairing: true,
projects: true,
workspaces: true,
providers: true,
@@ -4,7 +4,6 @@ import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
import { useUpdaterAction } from "@/shell/updates/action"
import { useSettingsSurface } from "@/settings/surface"
export function DesktopCommands() {
const command = useCommand()
@@ -41,25 +40,3 @@ export function DesktopCommands() {
return null
}
export function DesktopPairingCommand() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettingsSurface()
command.register("desktop-pairing", () =>
platform.platform === "desktop" && platform.pair
? [
{
id: "server.pair",
title: language.t("command.server.pair"),
category: language.t("command.category.server"),
onSelect: () => settings.open("pairing"),
},
]
: [],
)
return null
}
+88 -196
View File
@@ -1,20 +1,10 @@
import { useIsRouting, useLocation, useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, on, onCleanup, onMount, Show } from "solid-js"
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { base64Encode } from "@opencode/util/encode"
import {
applyProviderMetricEvent,
isProviderMetricEvent,
projectedProviderMetrics,
type ProviderMetrics,
type ProviderMetricState,
} from "./provider-metrics"
type Mem = Performance & {
memory?: {
@@ -49,23 +39,12 @@ const time = (n?: number) => {
return `${Math.round(n)}`
}
const fixed = (n?: number, digits = 0) => {
if (n === undefined || Number.isNaN(n)) return
return n.toFixed(digits)
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const duration = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n < 1_000) return `${Math.round(n)}ms`
return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}s`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
@@ -101,7 +80,6 @@ function Cell(props: {
}}
>
<div
dir="ltr"
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
@@ -109,7 +87,6 @@ function Cell(props: {
{props.label}
</div>
<div
dir="ltr"
classList={{
"uppercase font-bold tabular-nums": true,
"text-[11px] leading-text-tight": !!props.inline,
@@ -159,12 +136,8 @@ function ToggleCell(props: {
"flex-col items-center": !props.inline,
}}
>
<span dir="ltr" class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">
{props.label}
</span>
<span dir="ltr" class="text-[11px] leading-none font-bold">
{props.value}
</span>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
</span>
</button>
)
@@ -176,11 +149,9 @@ function ToggleCell(props: {
)
}
export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}) {
export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const params = useParams<{ serverKey?: string; id?: string }>()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -204,55 +175,8 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
dur: undefined as number | undefined,
pending: false,
},
live: undefined as ProviderMetrics | undefined,
})
const target = createMemo(
() => {
if (!params.serverKey || !params.id) return
const connection = global.servers
.list()
.find((item) => base64Encode(ServerConnection.key(item)) === params.serverKey)
if (!connection) return
return { ctx: global.ensureServerCtx(connection), id: params.id }
},
undefined,
{ equals: (a, b) => a?.ctx === b?.ctx && a?.id === b?.id },
)
// History comes from the already-loaded message projection; live requests refine it in place.
const projected = createMemo(() => {
const current = target()
if (!current) return
return projectedProviderMetrics(current.ctx.data.session.message.list(current.id))
})
const metrics = () => state.live ?? projected()
// Missed events during an outage are never replayed; the refreshed projection must win.
createEffect(
on(
() => target()?.ctx.sdk.connection.status(),
(status) => {
if (status !== "connected") setState("live", undefined)
},
{ defer: true },
),
)
createEffect(
on(target, (current) => {
setState("live", undefined)
if (!current) return
const accumulator: ProviderMetricState = {}
onCleanup(
current.ctx.sdk.event.listen((event) => {
if (!isProviderMetricEvent(event) || event.data.sessionID !== current.id) return
applyProviderMetricEvent(accumulator, event)
if (accumulator.latest) setState("live", accumulator.latest)
}),
)
}),
)
const na = () => language.t("debugBar.na").toUpperCase()
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
@@ -280,7 +204,6 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
let two = 0
createEffect(() => {
if (!props.diagnostics) return
const busy = routing()
const next = `${location.pathname}${location.search}`
@@ -325,7 +248,6 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
})
onMount(() => {
if (!props.diagnostics) return
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
@@ -526,7 +448,7 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
return (
<aside
aria-label={language.t(props.diagnostics ? "debugBar.ariaLabel" : "debugBar.providerAriaLabel")}
aria-label={language.t("debugBar.ariaLabel")}
classList={{
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
@@ -545,132 +467,102 @@ export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}
}}
>
<Cell
label={language.t("debugBar.tps.label")}
tip={language.t("debugBar.tps.tip")}
value={fixed(metrics()?.tps, 1) ?? na()}
dim={metrics()?.tps === undefined}
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.ttft.label")}
tip={language.t("debugBar.ttft.tip")}
value={duration(metrics()?.ttft) ?? na()}
dim={metrics()?.ttft === undefined}
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.ttfa.label")}
tip={language.t("debugBar.ttfa.tip")}
value={duration(metrics()?.ttfa) ?? na()}
dim={metrics()?.ttfa === undefined}
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.e2e.label")}
tip={language.t("debugBar.e2e.tip")}
value={duration(metrics()?.e2e) ?? na()}
dim={metrics()?.e2e === undefined}
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
inline={props.inline}
/>
<Show when={props.diagnostics}>
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
<Show when={platform.setForceFocus}>
<ToggleCell
active={language.direction() === "rtl"}
active={state.focus}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
<Show when={platform.setForceFocus}>
<ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
</Show>
</Show>
</div>
</aside>
@@ -1,153 +0,0 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
import type { ProviderMetricEvent } from "./provider-metrics"
import { foldProviderMetrics, projectedProviderMetrics } from "./provider-metrics"
const durable = { aggregateID: "ses_test", seq: 0, version: 1 } as const
const events: ProviderMetricEvent[] = [
{
id: "evt_started",
created: 1_000,
type: "session.step.started",
durable,
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
started: 1_000,
},
},
{
id: "evt_reasoning",
created: 1_300,
type: "session.reasoning.started",
durable: { ...durable, seq: 1 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
},
{
id: "evt_text",
created: 1_800,
type: "session.text.started",
durable: { ...durable, seq: 2 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
},
{
id: "evt_streamed",
created: 3_800,
type: "session.step.streamed",
durable: { ...durable, seq: 3 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant" },
},
{
id: "evt_ended",
created: 4_000,
type: "session.step.ended",
durable: { ...durable, seq: 4 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
},
},
]
test("calculates provider response metrics from durable events", () => {
expect(foldProviderMetrics(events)).toEqual({
tps: 50,
ttft: 300,
ttfa: 800,
e2e: 2_800,
})
})
test("ignores failed attempts without usage", () => {
expect(
foldProviderMetrics([
...events.slice(0, 4),
{
id: "evt_failed",
created: 4_000,
type: "session.step.failed",
durable: { ...durable, seq: 4 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
error: { type: "aborted", message: "Step interrupted" },
},
},
]),
).toBeUndefined()
})
test("keeps completed metrics while the next provider attempt runs", () => {
expect(
foldProviderMetrics([
...events,
{
id: "evt_retry",
created: 5_000,
type: "session.step.started",
durable: { ...durable, seq: 5 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
started: 5_000,
},
},
]),
).toEqual(foldProviderMetrics(events))
})
const assistant: SessionMessageAssistant = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "reasoning", text: "Think", time: { created: 1_300, completed: 1_700 } },
{ type: "text", text: "Answer" },
],
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
time: { created: 1_000, streamed: 3_800, completed: 4_000 },
}
test("derives a baseline from the latest completed projected request", () => {
const messages: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "Hi", time: { created: 1 } },
assistant,
{ ...assistant, id: "msg_running", tokens: undefined, time: { created: 5_000 } },
]
// Reasoning ended at 1_700, so TPS spans 1_700 → 3_800 = 100 / 2.1s.
expect(projectedProviderMetrics(messages)).toEqual({ tps: 100 / 2.1, ttft: 300, ttfa: 700, e2e: 2_800 })
})
const tool = (created: number): SessionMessageAssistant["content"][number] => ({
type: "tool",
id: "call_1",
name: "read",
state: { status: "running", input: {}, metadata: {} },
time: { created, ran: created + 100 },
})
test("leaves text-first history unavailable until a live request", () => {
const unavailable = { tps: undefined, ttft: undefined, ttfa: undefined, e2e: 2_800 }
expect(projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }] }])).toEqual(unavailable)
expect(
projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }, tool(2_500)] }]),
).toEqual(unavailable)
})
test("uses the first tool call as first output for tool-first history", () => {
expect(projectedProviderMetrics([{ ...assistant, content: [tool(1_800)] }])).toEqual({
tps: 50,
ttft: 800,
ttfa: undefined,
e2e: 2_800,
})
})
@@ -1,135 +0,0 @@
import type {
SessionLogItem,
SessionMessageAssistant,
SessionMessageInfo,
TokenUsageInfo,
} from "@opencode/client/promise"
type ProviderMetricEventType =
| "session.step.started"
| "session.step.streamed"
| "session.step.ended"
| "session.step.failed"
| "session.text.started"
| "session.reasoning.started"
| "session.tool.input.started"
const types: ReadonlySet<string> = new Set<ProviderMetricEventType>([
"session.step.started",
"session.step.streamed",
"session.step.ended",
"session.step.failed",
"session.text.started",
"session.reasoning.started",
"session.tool.input.started",
])
export type ProviderMetricEvent = Extract<SessionLogItem, { type: ProviderMetricEventType }>
export type ProviderMetrics = {
tps?: number
ttft?: number
ttfa?: number
e2e?: number
}
type Attempt = {
assistantMessageID: string
started: number
first?: number
answer?: number
streamed?: number
tokens?: TokenUsageInfo
}
export type ProviderMetricState = { attempt?: Attempt; latest?: ProviderMetrics }
export function isProviderMetricEvent(event: { type: string }): event is ProviderMetricEvent {
return types.has(event.type)
}
export function applyProviderMetricEvent(state: ProviderMetricState, event: ProviderMetricEvent) {
if (event.type === "session.step.started") {
state.attempt = {
assistantMessageID: event.data.assistantMessageID,
started: event.data.started,
}
return
}
if (!state.attempt || event.data.assistantMessageID !== state.attempt.assistantMessageID) return
if (
event.type === "session.text.started" ||
event.type === "session.reasoning.started" ||
event.type === "session.tool.input.started"
) {
state.attempt.first ??= event.created
if (event.type === "session.text.started") state.attempt.answer ??= event.created
return
}
if (event.type === "session.step.streamed") {
state.attempt.streamed = event.created
return
}
// Interrupted or failed attempts without usage would publish misleading partial numbers.
if (!event.data.tokens || state.attempt.first === undefined || state.attempt.streamed === undefined) return
state.attempt.tokens = event.data.tokens
state.latest = attemptMetrics(state.attempt)
}
export function foldProviderMetrics(events: readonly ProviderMetricEvent[]) {
const state: ProviderMetricState = {}
events.forEach((event) => applyProviderMetricEvent(state, event))
return state.latest
}
/**
* Baseline from already-loaded history. Text parts carry no start timestamp yet, so TTFT, TTFA,
* and TPS stay unavailable for text-first requests until a live request supplies them.
*/
export function projectedProviderMetrics(messages: readonly SessionMessageInfo[]): ProviderMetrics | undefined {
const message = messages.findLast(
(item): item is SessionMessageAssistant =>
item.type === "assistant" && item.time.streamed !== undefined && item.tokens !== undefined,
)
if (!message) return
// Content is chronological; only a non-text head carries the first-output time.
const head = message.content[0]
const first = head && head.type !== "text" ? head.time?.created : undefined
// Reasoning ends when the answer starts, so a reasoning part right before the first text
// approximates the live `session.text.started` timestamp.
const text = message.content.findIndex((item) => item.type === "text")
const before = text > 0 ? message.content[text - 1] : undefined
const answer = first !== undefined && before?.type === "reasoning" ? before.time?.completed : undefined
return attemptMetrics({
assistantMessageID: message.id,
started: message.time.created,
first,
answer,
streamed: message.time.streamed,
tokens: message.tokens,
})
}
function attemptMetrics(attempt: Attempt): ProviderMetrics {
const ttft = elapsed(attempt.started, attempt.first)
const ttfa = elapsed(attempt.started, attempt.answer)
const e2e = elapsed(attempt.started, attempt.streamed)
// Output tokens exclude reasoning, so measure them from the answer start when one exists.
const generation = elapsed(attempt.answer ?? attempt.first, attempt.streamed)
const output = attempt.tokens?.output
return {
tps: generation && output && generation > 0 && output > 0 ? output / (generation / 1_000) : undefined,
ttft,
ttfa,
e2e,
}
}
function elapsed(start: number | undefined, end: number | undefined) {
if (start === undefined || end === undefined) return
return Math.max(0, end - start)
}
+25 -53
View File
@@ -1,5 +1,5 @@
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,8 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
@@ -20,9 +18,6 @@ const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({
const ConnectServerScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
)
const ConnectLocalScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectLocalScreen })),
)
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -31,7 +26,6 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -39,53 +33,32 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
// From the hosted https page, http is mixed content and loopback is the scanning device itself.
const url =
pairing?.urls.find((item) => item === location.origin) ??
pairing?.urls.find((item) => location.protocol !== "https:" || item.startsWith("https:"))
onMount(() => {
if (!pairing || !url) return
servers.add({ type: "http", http: { url, password: pairing.password } })
navigate("/", { replace: true })
})
if (!pairing) return <ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
if (!url) return <ConnectLocalScreen urls={pairing.urls} />
return null
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
@@ -106,7 +79,6 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
+10 -23
View File
@@ -5,14 +5,11 @@ import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
import { UploadToastHost } from "@/composer/attachments/uploads"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { SshAuthentication } from "@/servers/ssh/authentication"
import { useUpdaterInstall } from "@/shell/updates/download"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -21,8 +18,6 @@ export default function Layout(props: ParentProps) {
const settings = useSettingsSurface()
const preferences = useSettings()
const installUpdate = useUpdaterInstall()
const command = useCommand()
const language = useLanguage()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({
debugTools: false,
@@ -39,21 +34,14 @@ export default function Layout(props: ParentProps) {
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
command.register("debug-bar", () => [
{
id: "debugBar.toggle",
title: language.t("command.debugBar.toggle"),
category: language.t("command.category.view"),
onSelect: debugTools.toggle,
},
])
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -117,13 +105,12 @@ export default function Layout(props: ParentProps) {
</SshAuthentication>
</main>
</div>
<Show when={state.debugTools}>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar diagnostics={import.meta.env.DEV} inline />
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
<UploadToastHost />
</div>
</TitlebarRightProvider>
)
@@ -10,10 +10,6 @@ test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
test("connect has its own layout route", () => {
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
-2
View File
@@ -67,7 +67,6 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "connect" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -107,7 +106,6 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "connect") return { type: "connect" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
@@ -303,7 +303,6 @@ export function Titlebar(props: {
return
}
case "settings":
case "connect":
case "home": {
const selection = layout.home.selection()
const conn =
@@ -34,15 +34,10 @@ describe("Composer attachment ownership", () => {
addPart: () => false,
setDraggingType() {},
directory: () => "C:/repo",
destination: () => ({
input: { image: true, pdf: true },
local: false,
upload: () => Promise.reject(new Error("native attachments must not upload")),
}),
isDialogActive: () => false,
warn() {},
duplicate() {},
onError: rejectTest,
onUploadError: rejectTest,
store: () => stored.promise,
})
+2 -15
View File
@@ -47,14 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
const update = yield* updater
.run((version) => {
installing = version
updateListeners.forEach((notify) => notify(version))
})
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
const update = yield* updater.run().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -99,13 +92,7 @@ export default Runtime.handler(Commands, (input) =>
),
{ signal },
),
check: (signal, notify) => {
if (installing) notify(installing)
updateListeners.add(notify)
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
updateListeners.delete(notify),
)
},
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
+1 -6
View File
@@ -2,7 +2,6 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -19,8 +18,6 @@ export default Runtime.handler(
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
// Fragment, not query: the credential must never reach app.opencode.ai.
const link = `https://app.opencode.ai/connect#${base64Encode(JSON.stringify(info))}`
process.stdout.write(
[
"",
@@ -31,13 +28,11 @@ export default Runtime.handler(
"",
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
"",
].join(EOL) + EOL,
)
+2 -3
View File
@@ -13,7 +13,7 @@ export type RunResult = { readonly type: "available" | "installed"; readonly ver
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly run: () => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
@@ -275,11 +275,10 @@ const make = Effect.gen(function* () {
})
const run = Effect.fn("cli.updater.run")(
function* (onInstall: (version: string) => void = () => {}) {
function* () {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
onInstall(result.version)
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
+12 -10
View File
@@ -10,16 +10,18 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
})
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
-59
View File
@@ -1,5 +1,4 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { ServerProcess } from "@opencode/server/process"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -8,69 +7,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
import { it } from "../../core/test/lib/effect"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
it.live("serves the web shell and assets before server authentication", () =>
Effect.gen(function* () {
const transform = yield* WebUi.handler({
assets: {
"index.html": "<html><body>connect</body></html>",
"_assets/app.js": "console.log('connect')",
"_assets/app.css": "body { color: black; }",
"icons/icon.svg": "<svg></svg>",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
"sw.js": "service worker",
},
})
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
transform,
)
const origin = HttpServer.formatAddress(server.address)
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
"/icons/icon.svg",
"/font.woff2",
"/sw.js",
],
(pathname) =>
Effect.gen(function* () {
yield* Effect.forEach(["GET", "HEAD"], (method) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
yield* Effect.promise(() => response.arrayBuffer())
}),
)
}),
)
yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
yield* Effect.promise(() => response.arrayBuffer())
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
-12
View File
@@ -1043,18 +1043,6 @@ export function createData(config: CreateDataInput) {
: "interrupted",
time: { created: event.created },
})
if (
store.session.message[event.data.sessionID]?.some(
(item) =>
item.type === "assistant" &&
item.content.some(
(part) => part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"),
),
)
) {
sync.invalidate(`session.message:${event.data.sessionID}`)
refresh(() => result.session.message.sync(event.data.sessionID))
}
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
-74
View File
@@ -52,80 +52,6 @@ test("uses the configured initial window and retains normal cursor page sizes",
}
})
test("reconciles a stale running tool when execution settles", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
let completed = false
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
requests++
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "provider", id: "model" },
time: { created: 1, ...(completed ? { completed: 2 } : {}) },
content: [
{
type: "tool",
id: "call_execute",
name: "execute",
time: { created: 1, ran: 1, ...(completed ? { completed: 2 } : {}) },
state: completed
? { status: "completed", input: {}, metadata: {}, content: [] }
: { status: "running", input: {}, metadata: {} },
},
],
},
],
cursor: {},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
completed = true
const interrupted: OpenCodeEvent = {
id: "evt_interrupted",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: { sessionID: "ses_refresh", reason: "user" },
}
listeners.forEach((listener) => listener({ name: interrupted.type, details: interrupted }))
await wait(
() =>
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.type === "tool" &&
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.state.status === "completed",
)
expect(requests).toBe(2)
expect(setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]).toMatchObject({
state: { status: "completed" },
})
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
-148
View File
@@ -1,148 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "CodeMode Transport Coverage",
"version": "1.0.0"
},
"paths": {
"/records/{recordID}": {
"get": {
"operationId": "records.get",
"parameters": [
{
"name": "recordID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Record"
}
}
}
}
}
}
},
"/events": {
"get": {
"operationId": "events.subscribe",
"responses": {
"200": {
"description": "Events",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/files/{path}": {
"get": {
"operationId": "files.read",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"put": {
"operationId": "files.write",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"responses": {
"204": {
"description": "Written"
}
}
}
},
"/terminals/{terminalID}/connect": {
"get": {
"operationId": "terminals.connect",
"x-websocket": true,
"parameters": [
{
"name": "terminalID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"101": {
"description": "Connected"
}
}
}
}
},
"components": {
"schemas": {
"Record": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["id", "value"],
"additionalProperties": false
}
}
}
}
+107 -44
View File
@@ -14,8 +14,8 @@ type Recorded = {
readonly body: unknown
}
const transportSpec = async (): Promise<Document> => {
return Bun.file(new URL("./fixtures/openapi-transports.json", import.meta.url)).json() as Promise<Document>
const opencodeSpec = async (): Promise<Document> => {
return Bun.file(new URL("../../protocol/openapi.json", import.meta.url)).json() as Promise<Document>
}
const happyPathSpec = async (): Promise<Document> => {
@@ -219,42 +219,48 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
})
test("generates supported operations and reports unsupported transports", async () => {
const spec = await transportSpec()
test("converts representative opencode operations into the expected tool shape", async () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toEqual([
{
method: "GET",
path: "/events",
reason: "SSE operations are not supported",
},
{
method: "GET",
path: "/files/{path}",
reason: "binary responses are not supported",
},
{
method: "PUT",
path: "/files/{path}",
reason: "request body has no JSON content (declared: application/octet-stream)",
},
{
method: "GET",
path: "/terminals/{terminalID}/connect",
reason: "WebSocket operations are not supported",
},
])
expect(result.skipped).toHaveLength(5)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
const get = toolAt(result.tools, "records.get")
expect(Tool.isTool(get)).toBe(true)
if (!Tool.isTool(get)) throw new Error("records.get was not generated")
expect(inputTypeScript(get)).toBe("{ recordID: string }")
expect(outputTypeScript(get)).toBe("{ id: string; value: string }")
expect(toolAt(result.tools, "events.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "files.read")).toBeUndefined()
expect(toolAt(result.tools, "files.write")).toBeUndefined()
expect(toolAt(result.tools, "terminals.connect")).toBeUndefined()
const sessionGet = toolAt(result.tools, "session.get")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "session.switchAgent")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "experimental.session.instructions.entry.put")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("experimental.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "experimental_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isTool(toolAt(result.tools, "pty.connect"))).toBe(false)
expect(toolAt(result.tools, "session.log")).toBeUndefined()
expect(toolAt(result.tools, "event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "fs.read")).toBeUndefined()
expect(toolAt(result.tools, "pty.connect.token")).not.toBeUndefined()
})
test("preserves operation path sanitization and collision handling", () => {
@@ -965,16 +971,30 @@ describe("OpenAPI.fromSpec", () => {
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
})
test("exposes generated operations through CodeMode discovery", async () => {
test("documents that the opencode fixture is unauthenticated", async () => {
const spec = await opencodeSpec()
const components = isRecord(spec.components) ? spec.components : {}
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
test("exposes real opencode operations through CodeMode discovery", async () => {
const { layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: await happyPathSpec(), baseUrl }).tools },
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
return search({ query: "get a user", namespace: "api", limit: 1 })
return search({ query: "server info", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -985,12 +1005,55 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.api.users.get",
description: "Get a user",
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
},
],
})
expect(JSON.stringify(result.value)).toContain("userId: string")
expect(JSON.stringify(result.value)).toContain("version: string")
})
test("invokes real opencode path parameters and JSON request bodies", async () => {
const { requests, layer } = recordingClient((request) => {
if (request.method === "GET") return json({ id: "ses_123" })
return json({ id: "ses_456" })
})
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
const existing = await tools.opencode.session.get({ sessionID: "ses_123" })
const created = await tools.opencode.session.create({ id: "ses_456" })
return { existing, created }
`,
)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests).toHaveLength(2)
expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
expect(requests[1]).toMatchObject({
method: "POST",
url: "http://localhost:4096/api/session",
body: { id: "ses_456" },
})
})
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get")
if (!Tool.isTool(location)) throw new Error("location.get was not generated")
await Effect.runPromise(location.execute({ location: { directory: "/tmp" } }).pipe(Effect.provide(client.layer)))
const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
})
test("serializes supported simple and form parameter shapes", async () => {
@@ -1398,15 +1461,15 @@ describe("OpenAPI.fromSpec", () => {
test("fails missing required parameters before auth and network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { api: OpenAPI.fromSpec({ spec: await transportSpec(), baseUrl }).tools },
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime.execute("return await tools.api.records.get({})").pipe(Effect.provide(layer)),
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Missing required path parameter 'recordID'")
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
expect(requests).toHaveLength(0)
})
+6 -12
View File
@@ -93,10 +93,9 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
input: PromptInput.FileAttachment,
) {
const label = attachmentLabel(input)
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri, label),
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
@@ -106,8 +105,8 @@ const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(f
: yield* readFileAttachment(input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: label,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${label}`,
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
@@ -139,7 +138,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const image = yield* Image.Service
const label = attachmentLabel(input)
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* image.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
@@ -202,12 +201,7 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
// A data URL is the whole file; errors and logs must name the attachment, not echo its bytes.
function attachmentLabel(input: PromptInput.FileAttachment) {
return input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
}
function decodeDataURL(uri: string, label: string) {
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
@@ -220,7 +214,7 @@ function decodeDataURL(uri: string, label: string) {
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri: label, message: `Invalid attachment data URL: ${label}` }),
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
+2 -25
View File
@@ -501,31 +501,8 @@ describe("Session.prompt", () => {
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri: "image.png",
message: "Invalid attachment data URL: image.png",
})
}),
)
it.effect("rejects oversized inline attachments without echoing their bytes", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const uri = `data:application/octet-stream;base64,${Buffer.alloc(20 * 1024 * 1024 + 1).toString("base64")}`
const error = yield* session
.prompt({
sessionID,
text: "Inspect this",
files: [{ uri }],
resume: false,
})
.pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri: "inline attachment",
message: "Attachment exceeds the 20971520 byte limit: inline attachment",
uri,
message: "Invalid attachment data URL",
})
}),
)
+4 -32
View File
@@ -76,7 +76,7 @@ import { SessionSystemPrompt } from "@opencode/core/session/system-prompt"
import { ID, Model } from "@opencode/core/model"
import { Location } from "@opencode/core/location"
import { Provider } from "@opencode/core/provider"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { asc, desc, eq, sql } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -3391,17 +3391,11 @@ describe("SessionRunnerLLM", () => {
scenario("consumes the full provider stream before recording its boundary and settling local tools", function* (s) {
yield* s.admit("Echo this")
const request = yield* s.llm.gate
const tail = yield* Deferred.make<void>()
const complete = yield* Deferred.make<void>()
const finished = yield* Deferred.make<void>()
yield* s.llm.push(
Stream.fromIterable(
TestLLM.complete(
{ reason: { normalized: "tool-calls" }, usage: { outputTokens: 100, reasoningTokens: 80 } },
LLMEvent.toolCall({ id: "call-streamed", name: "echo", input: { text: "hello" } }),
),
).pipe(
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
Stream.concat(
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
Stream.drain,
@@ -3419,39 +3413,25 @@ describe("SessionRunnerLLM", () => {
)
const run = yield* Effect.forkChild(s.resume)
yield* request.started
yield* TestClock.adjust("2 seconds")
yield* request.release
yield* tools.started
yield* Deferred.await(tail)
expect(s.requests).toHaveLength(1)
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.streamed.1")
expect(requireAssistant(yield* s.context).time.completed).toBeUndefined()
yield* TestClock.adjust("3 seconds")
yield* Deferred.succeed(complete, undefined)
yield* Fiber.join(streamed)
expect(yield* Deferred.isDone(finished)).toBe(true)
const assistant = requireAssistant(yield* s.context)
expect(assistant.time.streamed).toBeDefined()
expect(DateTime.toEpochMillis(assistant.time.streamed!) - DateTime.toEpochMillis(assistant.time.created)).toBe(
5_000,
)
expect(assistant.time.completed).toBeUndefined()
expect(assistant.content).toMatchObject([{ type: "tool", state: { status: "running" } }])
yield* TestClock.adjust("10 seconds")
yield* tools.release
yield* Fiber.join(run)
const events = yield* recordedEventTypes(sessionID)
expect(events.indexOf("session.step.streamed.1")).toBeLessThan(events.indexOf("session.tool.success.2"))
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.ended.1"))
expect(events.filter((type) => type === "session.step.streamed.1")).toHaveLength(2)
yield* replaySessionProjection(sessionID)
const replayed = (yield* s.context).find((message) => message.id === assistant.id)
expect(replayed).toMatchObject({
time: { created: assistant.time.created, streamed: assistant.time.streamed },
tokens: { output: 20, reasoning: 80 },
})
})
scenario("restores durable reasoning provider metadata in the next request", function* (s) {
@@ -5057,18 +5037,14 @@ describe("SessionRunnerLLM", () => {
scenario(`bounds jittered exponential backoff before output for ${failure.name}`, function* (s) {
yield* s.admit("Retry transport")
yield* s.llm.push(TestLLM.failAfter(failure(), LLMEvent.stepStart({ index: 0 })))
yield* s.llm.push(
Stream.fromEffect(Effect.sleep(400)).pipe(
Stream.flatMap(() => Stream.fromIterable(TestLLM.text("Recovered", "retry-success"))),
),
)
yield* s.llm.push(TestLLM.text("Recovered", "retry-success"))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Queue.take(scheduled)
yield* TestClock.adjust("1599 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1201 millis")
yield* TestClock.adjust("801 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
@@ -5082,10 +5058,6 @@ describe("SessionRunnerLLM", () => {
])
yield* replaySessionProjection(sessionID)
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
const assistant = requireAssistant(yield* s.context)
expect(DateTime.toEpochMillis(assistant.time.streamed!) - DateTime.toEpochMillis(assistant.time.created)).toBe(
400,
)
})
}
-4
View File
@@ -45,10 +45,6 @@ function selectOptions(): DevOptions {
async function prepareServer(source: ServerSource) {
if (source.type === "download")
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
await $`bun run --cwd ${join(import.meta.dirname, "../../app")} build`.env({
...process.env,
VITE_OPENCODE_SERVER_MODE: "origin",
})
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
await $`bun run --cwd ${process.env.OPENCODE_DESKTOP_CLI_DEV} --define=OPENCODE_VERSION=${JSON.stringify(process.env.OPENCODE_VERSION)} src/index.ts --version`
if (process.platform !== "win32") return
@@ -5,7 +5,6 @@ import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { createScreenActivity } from "../native/screen-activity"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
@@ -18,8 +17,6 @@ import { DesktopCli } from "../service/desktop-cli"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { DesktopStorage } from "../storage"
import { createPairing } from "../service/pairing"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
import { sender } from "./context"
@@ -31,10 +28,6 @@ export const appHandlers = AppRpcs.toLayer(
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const storage = yield* DesktopStorage.Service
const screenActivity = createScreenActivity(storage)
yield* Effect.addFinalizer(() => Effect.sync(screenActivity.dispose))
const pairing = createPairing(storage)
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
@@ -75,14 +68,6 @@ export const appHandlers = AppRpcs.toLayer(
})
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
AppPairInfo: () => pair(pairing.info),
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
AppSetKeepScreenActive: ({ enabled }) =>
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
AppPairTailscaleAvailable: () => Effect.promise(pairing.tailscaleAvailable),
AppPairTailscaleStatus: () => pair(pairing.tailscaleStatus),
AppPairOpenTailscale: () => pair(pairing.openTailscale),
AppPairDisableTailscale: () => pair(pairing.disableTailscale),
})
}),
)
@@ -90,10 +75,3 @@ export const appHandlers = AppRpcs.toLayer(
function promise<A>(evaluate: () => A | Promise<A>) {
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
}
function pair<A>(evaluate: () => Promise<A>) {
return Effect.tryPromise({
try: evaluate,
catch: (cause) => (cause instanceof Error ? cause.message : String(cause)),
})
}
+2 -3
View File
@@ -1,14 +1,13 @@
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { createIpcCodec } from "../shared/ipc-codec"
import { bindIpcEvents } from "./ipc-events"
type PortBinding = {
readonly id: number
readonly sender: WebContents
readonly port: MessagePortMain
readonly parser: ReturnType<typeof createIpcCodec>
readonly parser: RpcSerialization.Parser
readonly onMessage: (event: Electron.MessageEvent) => void
readonly onClose: () => void
readonly unbindEvents: Effect.Effect<void>
@@ -59,7 +58,7 @@ export const IpcServerProtocolLive = Layer.unwrap(
}
const id = nextClientId++
const parser = createIpcCodec(serialization)
const parser = serialization.makeUnsafe()
const onMessage = (event: Electron.MessageEvent) => {
try {
parser
@@ -1,25 +0,0 @@
import { powerSaveBlocker } from "electron"
import type { DesktopStorage } from "../storage"
import { KEEP_SCREEN_ACTIVE_KEY, SETTINGS_STORE } from "../storage/keys"
export function createScreenActivity(storage: DesktopStorage.Interface) {
const state = { blocker: undefined as number | undefined }
const dispose = () => {
if (state.blocker === undefined) return
powerSaveBlocker.stop(state.blocker)
state.blocker = undefined
}
const set = (enabled: boolean) => {
if (enabled && state.blocker === undefined) {
state.blocker = powerSaveBlocker.start("prevent-display-sleep")
}
if (!enabled) dispose()
storage.state.set(SETTINGS_STORE, KEEP_SCREEN_ACTIVE_KEY, JSON.stringify(enabled))
}
if (storage.state.get(SETTINGS_STORE, KEEP_SCREEN_ACTIVE_KEY) === "true") set(true)
return {
get: () => state.blocker !== undefined && powerSaveBlocker.isStarted(state.blocker),
set,
dispose,
}
}
@@ -43,7 +43,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--hostname", "0.0.0.0", "--port", "0"] : [])],
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
}),
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test"
import { tailscaleUrls } from "./pairing"
describe("Tailscale Serve output", () => {
test("extracts and deduplicates HTTPS addresses", () => {
expect(
tailscaleUrls(`
Available within your tailnet:
https://computer.example.ts.net/
|-- https://computer.example.ts.net
|--> http://127.0.0.1:4096
`),
).toEqual(["https://computer.example.ts.net"])
})
test("ignores non-HTTPS addresses", () => {
expect(tailscaleUrls("http://computer:80\ntcp://100.64.0.1:443")).toEqual([])
})
test("selects only the OpenCode HTTPS port", () => {
expect(
tailscaleUrls(
"https://computer.example.ts.net\nhttps://computer.example.ts.net:8443\nhttps://computer.example.ts.net:49152",
49152,
),
).toEqual(["https://computer.example.ts.net:49152"])
})
})
@@ -1,164 +0,0 @@
import { eq } from "drizzle-orm"
import { execFile } from "node:child_process"
import { constants } from "node:fs"
import { access } from "node:fs/promises"
import { createServer } from "node:net"
import { promisify } from "node:util"
import type { DesktopStorage } from "../storage"
import { pairing } from "../storage/schema"
import { SidecarCredentials } from "./sidecar-credentials"
const tailscalePortKey = "tailscale_https_port"
const execFileAsync = promisify(execFile)
export function createPairing(storage: DesktopStorage.Interface) {
let tailscale: Promise<string | undefined> | undefined
const getTailscale = () => (tailscale ??= resolveTailscale())
let tailscalePort: Promise<number> | undefined
const storedTailscalePort = () => {
const value = storage.db
.select({ value: pairing.value })
.from(pairing)
.where(eq(pairing.key, tailscalePortKey))
.get()?.value
if (!value) return
const port = Number(value)
if (Number.isInteger(port) && port > 0 && port <= 65_535) return port
}
const getTailscalePort = () => (tailscalePort ??= Promise.resolve(storedTailscalePort() ?? availablePort()))
const saveTailscalePort = (port: number) =>
storage.db
.insert(pairing)
.values({ key: tailscalePortKey, value: String(port) })
.onConflictDoUpdate({ target: pairing.key, set: { value: String(port) } })
.run()
const requireCredentials = () => {
const credentials = SidecarCredentials.get()
if (!credentials) throw new Error("The local desktop server is not ready")
return credentials
}
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
const { OpenCode } = await import("@opencode/client/promise")
const info = await OpenCode.make({
baseUrl: credentials.url,
headers: credentials.password
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
: undefined,
}).server.info()
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
}
const info = () => readInfo(requireCredentials())
const serveTailscale = async (executable: string, port: number) => {
const credentials = requireCredentials()
const local = await readInfo(credentials)
const endpoint = new URL(credentials.url)
const options = { env: { ...process.env, TAILSCALE_BE_CLI: "1" }, windowsHide: true }
const served = await execFileAsync(
executable,
["serve", `--https=${port}`, "--bg", "--yes", `http://127.0.0.1:${endpoint.port}`],
options,
)
saveTailscalePort(port)
const direct = tailscaleUrls(`${served.stdout}\n${served.stderr}`, port)
const urls = direct.length
? direct
: tailscaleUrls(
await execFileAsync(executable, ["serve", "status"], options).then(
(result) => `${result.stdout}\n${result.stderr}`,
),
port,
)
if (!urls.length) throw new Error("Tailscale Serve did not report an HTTPS address")
return { ...local, urls: [...urls, ...local.urls] }
}
return {
info,
async tailscaleAvailable() {
return (await getTailscale()) !== undefined
},
async tailscaleStatus() {
const executable = await getTailscale()
const port = storedTailscalePort()
if (!executable || !port) return null
return serveTailscale(executable, port)
},
async openTailscale() {
const executable = await getTailscale()
if (!executable) throw new Error("Tailscale is not installed")
return serveTailscale(executable, await getTailscalePort())
},
async disableTailscale() {
const executable = await getTailscale()
const port = storedTailscalePort()
if (!executable || !port) return
await execFileAsync(executable, ["serve", `--https=${port}`, "--yes", "off"], {
env: { ...process.env, TAILSCALE_BE_CLI: "1" },
windowsHide: true,
})
storage.db.delete(pairing).where(eq(pairing.key, tailscalePortKey)).run()
tailscalePort = undefined
},
}
}
async function resolveTailscale() {
const candidates =
process.platform === "darwin"
? [
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
...(process.env.HOME ? [`${process.env.HOME}/Applications/Tailscale.app/Contents/MacOS/Tailscale`] : []),
"/opt/homebrew/bin/tailscale",
"/usr/local/bin/tailscale",
]
: []
const installed = (
await Promise.all(
candidates.map((file) =>
access(file, constants.X_OK).then(
() => file,
() => undefined,
),
),
)
).find((file) => file !== undefined)
if (installed) return installed
const result = await execFileAsync(process.platform === "win32" ? "where.exe" : "which", ["tailscale"], {
windowsHide: true,
}).then(
(value) => value.stdout,
() => undefined,
)
return result
?.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean)
}
export function tailscaleUrls(output: string, port?: number) {
return [
...new Set(
(output.match(/https:\/\/[^\s|]+/g) ?? [])
.map((value) => URL.parse(value.replace(/[),;]+$/, "")))
.filter((url): url is URL => url?.protocol === "https:" && (port === undefined || url.port === String(port)))
.map((url) => url.href.replace(/\/$/, "")),
),
]
}
function availablePort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.once("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (address === null || typeof address === "string") {
server.close()
reject(new Error("Could not allocate a Tailscale HTTPS port"))
return
}
server.close((error) => (error ? reject(error) : resolve(address.port)))
})
})
}
@@ -14,7 +14,7 @@ const tables = (db: ReturnType<typeof drizzle>) =>
describe("database", () => {
test("bootstraps every table on a fresh database and is idempotent", () => {
const database = openDatabase(":memory:")
expect(tables(database.db)).toEqual(["blob", "document", "migration", "pairing", "state"])
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
expect(migrate(database.db)).toEqual([])
database.close()
})
@@ -26,7 +26,7 @@ describe("database", () => {
)
const db = drizzle({ client: native })
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
expect(tables(db)).toEqual(["blob", "document", "migration", "pairing", "state"])
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
expect(migrate(db)).toEqual([])
})
@@ -3,6 +3,5 @@ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const KEEP_SCREEN_ACTIVE_KEY = "keepScreenActive"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
@@ -18,8 +18,4 @@ export const migrations = [
id: "20260907031611_blob-touched",
statements: ["ALTER TABLE `blob` ADD `touched_at` integer DEFAULT 0 NOT NULL;"],
},
{
id: "20260915071310_pairing-state",
statements: ["CREATE TABLE `pairing` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);"],
},
]
@@ -1,4 +0,0 @@
CREATE TABLE `pairing` (
`key` text PRIMARY KEY,
`value` text NOT NULL
);
@@ -1,174 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"id": "bfb5a007-b9ab-4835-be4e-45f0bd6d3ddb",
"prevIds": [
"53c65132-8703-42d6-8464-64356145dfb4"
],
"ddl": [
{
"name": "blob",
"entityType": "tables"
},
{
"name": "document",
"entityType": "tables"
},
{
"name": "pairing",
"entityType": "tables"
},
{
"name": "state",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "blob"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "blob"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "touched_at",
"entityType": "columns",
"table": "blob"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "document"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "pairing"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "pairing"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "state"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "state"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "state"
},
{
"columns": [
"name",
"key"
],
"nameExplicit": false,
"name": "state_pk",
"entityType": "pks",
"table": "state"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "blob_pk",
"table": "blob",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "document_pk",
"table": "document",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "pairing_pk",
"table": "pairing",
"entityType": "pks"
}
],
"renames": []
}
@@ -27,10 +27,3 @@ export const state = sqliteTable(
},
(table) => [primaryKey({ columns: [table.name, table.key] })],
)
// Main-process pairing integration state. Tailscale Serve persists its configuration, and this
// remembers which HTTPS port belongs to OpenCode so it can be recovered without touching other routes.
export const pairing = sqliteTable("pairing", {
key: text().primaryKey(),
value: text().notNull(),
})
@@ -15,7 +15,6 @@ import type {
ServerReadyData,
TitlebarTheme,
} from "../shared/ipc-contract"
import type { PairingInfo } from "../shared/ipc-rpc/app"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
@@ -88,11 +87,4 @@ export type ElectronAPI = {
setForceFocus(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
pairInfo(): Promise<typeof PairingInfo.Type>
getKeepScreenActive(): Promise<boolean>
setKeepScreenActive(enabled: boolean): Promise<void>
pairTailscaleAvailable(): Promise<boolean>
pairTailscaleStatus(): Promise<typeof PairingInfo.Type | null>
pairOpenTailscale(): Promise<typeof PairingInfo.Type>
pairDisableTailscale(): Promise<void>
}
-7
View File
@@ -149,11 +149,4 @@ export const api: ElectronAPI = {
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
pairInfo: () => invoke("AppPairInfo").then(mutable),
getKeepScreenActive: () => invoke("AppGetKeepScreenActive"),
setKeepScreenActive: (enabled) => invoke("AppSetKeepScreenActive", { enabled }),
pairTailscaleAvailable: () => invoke("AppPairTailscaleAvailable"),
pairTailscaleStatus: () => invoke("AppPairTailscaleStatus").then(mutable),
pairOpenTailscale: () => invoke("AppPairOpenTailscale").then(mutable),
pairDisableTailscale: () => invoke("AppPairDisableTailscale"),
}
+1 -2
View File
@@ -1,6 +1,5 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
import { createIpcCodec } from "../shared/ipc-codec"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
@@ -86,7 +85,7 @@ function clientProtocol(value: MessagePort) {
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = createIpcCodec(serialization)
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
try {
@@ -79,8 +79,6 @@ export function createDesktopPlatform(
windowFullscreen,
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
setPinchZoomEnabled,
getKeepScreenActive: () => api.getKeepScreenActive(),
setKeepScreenActive: (enabled) => api.setKeepScreenActive(enabled),
onDragCancel: (callback) => {
window.addEventListener(DragCancelEvent, callback)
return () => window.removeEventListener(DragCancelEvent, callback)
@@ -89,13 +87,6 @@ export function createDesktopPlatform(
checkAppExists: async (appName) => {
return api.checkAppExists(appName)
},
pair: {
info: () => api.pairInfo(),
tailscaleAvailable: () => api.pairTailscaleAvailable(),
tailscaleStatus: () => api.pairTailscaleStatus(),
openTailscale: () => api.pairOpenTailscale(),
disableTailscale: () => api.pairDisableTailscale(),
},
}
}
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import { RpcSerialization } from "effect/unstable/rpc"
import { createIpcCodec, ipcLargeMessageBytes } from "./ipc-codec"
type Serialization = RpcSerialization.RpcSerialization["Service"]
const msgpack = RpcSerialization.makeMsgPack()
function counting(serialization: Serialization) {
let created = 0
const counted: Serialization = {
...serialization,
makeUnsafe: () => {
created++
return serialization.makeUnsafe()
},
}
return { created: () => created, serialization: counted }
}
describe("ipc codec", () => {
test("posts an exact-size buffer instead of a view into the shared target", () => {
const codec = createIpcCodec(msgpack)
// The msgpack target starts at 8 KiB, so a view would drag a larger backing store along.
const encoded = codec.encode({ _tag: "Request", id: "1", tag: "Ping", payload: {} })
expect(encoded).toBeInstanceOf(Uint8Array)
const bytes = encoded as Uint8Array
expect(bytes.byteOffset).toBe(0)
expect(bytes.buffer.byteLength).toBe(bytes.byteLength)
})
test("copies Node Buffers, whose slice is only a view", () => {
const backing = new ArrayBuffer(1024 * 1024)
const view = Buffer.from(backing, 16, 32)
view.fill(7)
const stub: Serialization = { ...msgpack, makeUnsafe: () => ({ decode: () => [], encode: () => view }) }
const encoded = createIpcCodec(stub).encode({}) as Uint8Array
expect(encoded.buffer).not.toBe(backing)
expect(encoded.buffer.byteLength).toBe(32)
expect([...encoded]).toEqual(Array(32).fill(7))
})
test("replaces the encoder after a large message and keeps the decoder", () => {
const spy = counting(msgpack)
const codec = createIpcCodec(spy.serialization)
expect(spy.created()).toBe(2)
codec.encode({ small: true })
expect(spy.created()).toBe(2)
codec.encode({ large: "x".repeat(ipcLargeMessageBytes) })
expect(spy.created()).toBe(3)
codec.decode(codec.encode({ after: 1 }) as Uint8Array)
expect(spy.created()).toBe(3)
})
test("round-trips messages through the wrapped serialization", () => {
const client = createIpcCodec(msgpack)
const server = createIpcCodec(msgpack)
const message = { _tag: "Request", id: "7", tag: "DraftsSet", payload: { key: "k", value: "v" } }
expect(server.decode(client.encode(message) as Uint8Array)).toEqual([message])
})
})
-25
View File
@@ -1,25 +0,0 @@
import type { RpcSerialization } from "effect/unstable/rpc"
// After a message this large the encoder is replaced so its grown target buffer can be collected.
export const ipcLargeMessageBytes = 1024 * 1024
// The MessagePack parser packs into one shared, grow-only target buffer and returns a view into it.
// Posting that view structured-clones the whole backing buffer, so after one large message every
// later message (even a 55-byte ack) would copy the full grown buffer across processes on each
// send. Encoding through this wrapper posts an exact-size copy instead. Decoding keeps a single
// parser for the connection: record structures the peer defined inline must stay known.
export function createIpcCodec(serialization: RpcSerialization.RpcSerialization["Service"]) {
const decoder = serialization.makeUnsafe()
let encoder = serialization.makeUnsafe()
return {
decode: (bytes: Uint8Array | string) => decoder.decode(bytes),
encode(message: unknown) {
const encoded = encoder.encode(message)
if (!(encoded instanceof Uint8Array)) return encoded
// Not `.slice()`: in the main process the packer hands out a Node Buffer, whose slice is a view.
const copy = new Uint8Array(encoded)
if (copy.byteLength > ipcLargeMessageBytes) encoder = serialization.makeUnsafe()
return copy
},
}
}
@@ -5,12 +5,6 @@ const ServerReadyData = Schema.Struct({
url: Schema.String,
})
export const PairingInfo = Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
@@ -59,19 +53,6 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
payload: { value: Schema.Unknown },
})
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppPairInfo = Rpc.make("AppPairInfo", { success: PairingInfo, error: Schema.String })
export const AppPairTailscaleAvailable = Rpc.make("AppPairTailscaleAvailable", { success: Schema.Boolean })
export const AppPairTailscaleStatus = Rpc.make("AppPairTailscaleStatus", {
success: Schema.NullOr(PairingInfo),
error: Schema.String,
})
export const AppPairOpenTailscale = Rpc.make("AppPairOpenTailscale", { success: PairingInfo, error: Schema.String })
export const AppPairDisableTailscale = Rpc.make("AppPairDisableTailscale", { error: Schema.String })
export const AppGetKeepScreenActive = Rpc.make("AppGetKeepScreenActive", { success: Schema.Boolean })
export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
payload: { enabled: Schema.Boolean },
error: Schema.String,
})
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppReconnectService,
@@ -88,11 +69,4 @@ export const AppRpcs = RpcGroup.make(
AppRecordFatalRendererError,
AppSetNativeTranslations,
AppRelaunch,
AppPairInfo,
AppPairTailscaleAvailable,
AppPairTailscaleStatus,
AppPairOpenTailscale,
AppPairDisableTailscale,
AppGetKeepScreenActive,
AppSetKeepScreenActive,
)
+17 -6
View File
@@ -8,7 +8,14 @@ import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { Global } from "@opencode/util/global"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
HttpMiddleware,
HttpPlatform,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
@@ -61,17 +68,15 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}
const application = yield* Ref.make(Option.none<App>())
const app = dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp)
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(
(transform ? transform(app) : app).pipe(
HttpMiddleware.compression(),
dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp).pipe(
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
),
errorResponseLogger,
)
.pipe(Effect.provide(NodeHttpServer.layerHttpServices), withoutParentSpan)
.pipe(withoutParentSpan)
if (lifecycle)
yield* lifecycle.onListen(bound.http.address, shutdown.open.pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) =>
@@ -105,7 +110,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
Effect.provideService(Scope.Scope, applicationScope),
)
}
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
const app = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
HttpMiddleware.compression(),
Effect.provideService(HttpPlatform.HttpPlatform, Context.get(context, HttpPlatform.HttpPlatform)),
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
const bus = Context.get(context, Bus.Service)
return {
+11 -12
View File
@@ -1,10 +1,10 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("authenticates API requests behind the frontend transform while allowing browser preflight", () =>
it.live("authenticates API and frontend requests while allowing browser preflight", () =>
Effect.gen(function* () {
const fallback = "fallback".repeat(256)
const server = yield* ServerProcess.start<never, never>(
@@ -18,13 +18,12 @@ it.live("authenticates API requests behind the frontend transform while allowing
},
undefined,
(api) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api
return HttpServerResponse.raw(fallback, { contentType: "text/plain" })
}),
api.pipe(
Effect.catchIf(
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
() => Effect.succeed(HttpServerResponse.raw(fallback, { contentType: "text/plain" })),
),
),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
@@ -144,9 +143,9 @@ it.live("authenticates API requests behind the frontend transform while allowing
headers: authorization ? { authorization } : undefined,
}),
)
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
expect(yield* Effect.promise(() => response.text())).toBe(method === "HEAD" ? "" : fallback)
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
expect(yield* Effect.promise(() => response.text())).toBe("")
}),
)
const response = yield* Effect.promise(() =>
@@ -0,0 +1,31 @@
import { expect, story } from "../../storybook/playwright/story"
story("shows Code Mode child calls and expands their inputs", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--execute-code")
const calls = timeline.locator('[data-component="execute-tool-call"]')
await expect(calls).toHaveCount(3)
const completed = calls.nth(0)
const trigger = completed.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger).toContainText("planetscale.planetscale_execute_write_query")
await expect(trigger).toContainText("organization=anomalyco")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(completed.locator("dt")).toHaveText([
"organization",
"database",
"branch",
"confirm_destructive",
"query",
])
await expect(completed.locator("dd")).toContainText([
"anomalyco",
"opencode",
"production",
"true",
"UPDATE workspace",
])
await expect(calls.nth(2)).toHaveAttribute("data-status", "completed")
})
@@ -200,6 +200,107 @@
}
}
[data-component="execute-tool-calls"] {
margin-inline-start: 12px;
padding-inline-start: 12px;
border-inline-start: 1px solid var(--v2-border-border-weak);
}
[data-component="execute-tool-call"] {
min-width: 0;
&[data-open="true"] [data-slot="execute-tool-call-status"] [data-slot="icon-svg"] {
transform: rotate(90deg);
}
&[data-status="error"] [data-slot="execute-tool-call-status"] {
color: var(--text-error);
}
[data-slot="execute-tool-call-trigger"] {
width: 100%;
height: 28px;
min-width: 0;
padding: 0;
display: flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: var(--v2-text-text-muted);
text-align: start;
cursor: pointer;
&:disabled {
cursor: default;
}
&:hover:not(:disabled),
&:focus-visible {
color: var(--v2-text-text-base);
}
}
[data-slot="execute-tool-call-status"] {
width: 16px;
height: 16px;
flex: 0 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
[data-component="session-progress-indicator-v2"] {
width: 16px;
height: 16px;
}
}
[data-slot="execute-tool-call-title"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-mono);
font-size: 13px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-compact);
color: inherit;
}
[data-slot="execute-tool-call-details"] {
margin: 0;
padding: 2px 0 8px 22px;
min-width: 0;
}
[data-slot="execute-tool-call-detail"] {
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 8px;
font-family: var(--font-family-mono);
font-size: 13px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
dt {
color: var(--v2-text-text-muted);
}
dd {
min-width: 0;
margin: 0;
color: var(--v2-text-text-base);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
}
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
@@ -545,6 +545,31 @@ export const executeCodeDocument = document([
code: 'const greeting = "Code Mode execute completed"\nreturn { greeting, timestamp: new Date().toISOString() }',
},
output: '{\n "greeting": "Code Mode execute completed",\n "timestamp": "2026-08-17T09:01:43.590Z"\n}',
metadata: {
toolCalls: [
{
tool: "planetscale.planetscale_execute_write_query",
status: "completed",
input: {
organization: "anomalyco",
database: "opencode",
branch: "production",
confirm_destructive: true,
query: "UPDATE workspace SET time_deleted = UTC_TIMESTAMP(3) WHERE time_deleted IS NULL LIMIT 50000",
},
},
{
tool: "planetscale.planetscale_execute_write_query",
status: "completed",
input: { organization: "anomalyco", database: "opencode", branch: "production" },
},
{
tool: "planetscale.planetscale_execute_write_query",
status: "completed",
input: { organization: "anomalyco", database: "opencode", branch: "production", limit: 50000 },
},
],
},
}),
],
}),
+91 -23
View File
@@ -1724,37 +1724,105 @@ ToolRegistry.register({
const pending = () => props.status === "streaming" || props.status === "running"
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
const output = () => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const sawPending = pending()
return (
<BasicTool
{...props}
icon="console"
rail={false}
hasContent
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
</Show>
<div data-component="execute-tool">
<BasicTool
{...props}
icon="console"
rail={false}
hasContent
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
</Show>
</div>
</div>
)}
>
<ConsoleOutput copy={code()} variant="shell">
<span data-slot="bash-command">{code()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
</BasicTool>
<Show when={calls().length > 0}>
<div data-component="execute-tool-calls">
<Index each={calls()}>{(call) => <ExecuteCallRow call={call} />}</Index>
</div>
)}
>
<ConsoleOutput copy={code()} variant="shell">
<span data-slot="bash-command">{code()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
</BasicTool>
</Show>
</div>
)
},
})
type ExecuteCall = {
tool: string
status: "running" | "completed" | "error"
input?: Record<string, unknown>
}
function executeCalls(value: unknown): ExecuteCall[] {
if (!Array.isArray(value)) return []
return value.flatMap((call) => {
if (!record(call) || typeof call.tool !== "string") return []
if (call.status !== "running" && call.status !== "completed" && call.status !== "error") return []
return [{ tool: call.tool, status: call.status, input: record(call.input) ? call.input : undefined }]
})
}
function ExecuteCallRow(props: { call: () => ExecuteCall }) {
const [open, setOpen] = createSignal(false)
const input = createMemo(() => Object.entries(props.call().input ?? {}))
const summary = createMemo(() => {
const args = input()
.filter(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
.map(([key, value]) => `${key}=${String(value)}`)
.join(", ")
.replace(/\s+/g, " ")
return `${props.call().tool}${args ? ` [${args}]` : ""}`
})
return (
<div data-component="execute-tool-call" data-status={props.call().status} data-open={open() ? "true" : "false"}>
<button
type="button"
data-slot="execute-tool-call-trigger"
disabled={input().length === 0}
aria-expanded={input().length > 0 ? open() : undefined}
onClick={() => setOpen((value) => !value)}
>
<span data-slot="execute-tool-call-status" aria-hidden="true">
<Show when={props.call().status !== "running"} fallback={<SessionProgressIndicatorV2 />}>
<Show when={props.call().status === "error"} fallback={<Icon name="chevron-right" size="small" />}>
×
</Show>
</Show>
</span>
<span data-slot="execute-tool-call-title">{open() ? props.call().tool : summary()}</span>
</button>
<Show when={open()}>
<dl data-slot="execute-tool-call-details">
<For each={input()}>
{([key, value]) => (
<div data-slot="execute-tool-call-detail">
<dt>{key}</dt>
<dd>{typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value))}</dd>
</div>
)}
</For>
</dl>
</Show>
</div>
)
}
ToolRegistry.register({
name: "shell",
render(props) {
+14 -16
View File
@@ -136,10 +136,9 @@ export const DEFAULT_THEME = {
},
background: {
default: "$hue.neutral.200",
raised: {
base: "$hue.neutral.300",
high: "$hue.neutral.400",
max: "$hue.neutral.500",
surface: {
offset: "$hue.neutral.300",
overlay: "$hue.neutral.400",
},
action: {
primary: {
@@ -162,7 +161,7 @@ export const DEFAULT_THEME = {
},
formfield: {
default: "$background.default",
$hovered: "$background.raised.base",
$hovered: "$background.surface.offset",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
@@ -221,14 +220,14 @@ export const DEFAULT_THEME = {
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.surface.overlay" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.raised.high",
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.500" } },
},
},
@@ -358,10 +357,9 @@ export const DEFAULT_THEME = {
},
background: {
default: "$hue.neutral.800",
raised: {
base: "$hue.neutral.700",
high: "$hue.neutral.600",
max: "$hue.neutral.500",
surface: {
offset: "$hue.neutral.700",
overlay: "$hue.neutral.600",
},
action: {
primary: {
@@ -384,7 +382,7 @@ export const DEFAULT_THEME = {
},
formfield: {
default: "$background.default",
$hovered: "$background.raised.base",
$hovered: "$background.surface.offset",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
@@ -443,14 +441,14 @@ export const DEFAULT_THEME = {
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.base",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.raised.high",
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.400" } },
},
},
+1 -1
View File
@@ -15,7 +15,7 @@ export function fallback(mode: Mode): ThemeTokensDefinition {
},
background: {
default: red,
raised: { base: red, high: red, max: red },
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
+3 -4
View File
@@ -129,11 +129,10 @@ export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
const BackgroundDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
raised: Schema.optional(
surface: Schema.optional(
Schema.Struct({
base: Schema.optional(ColorValue),
high: Schema.optional(ColorValue),
max: Schema.optional(ColorValue),
offset: Schema.optional(ColorValue),
overlay: Schema.optional(ColorValue),
}),
),
action: Schema.optional(ActionColorDefinition),

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