mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 15:17:51 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62daf48a8a |
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
},
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {} },
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 computer’s 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.",
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) : ""
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
-174
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,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])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }])),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user