mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-17 14:26:26 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733e7852ef | ||
|
|
a5b3802ca3 | ||
|
|
f28d1b44e3 | ||
|
|
b81be469c8 | ||
|
|
fca4a8fa3b | ||
|
|
8520617ca8 | ||
|
|
ab3566ab82 | ||
|
|
b642c1d9ea | ||
|
|
c8cc984aa0 | ||
|
|
5a9448acbb | ||
|
|
f6604cd367 | ||
|
|
acfacede2d | ||
|
|
4a27842fe6 | ||
|
|
d797722187 | ||
|
|
7390832f13 | ||
|
|
7689c3654e | ||
|
|
7df0935ada | ||
|
|
bcd43760df | ||
|
|
9073c522ef | ||
|
|
04c296310e | ||
|
|
79d657b8fe | ||
|
|
606ec4fa38 |
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
### [x] `GET /api/health` and `GET /api/server`
|
||||
|
||||
- **Decision:** Merge and rename
|
||||
- **Replacement:** `GET /api/status` with operation ID `server.status`.
|
||||
- **Replacement:** `GET /api/info` with operation ID `server.info`.
|
||||
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001–002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 001–002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
|
||||
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
|
||||
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
|
||||
|
||||
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
it.effect("does not let server codes override a 4xx rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(Array(5).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies 402 as exhausted quota", () => {
|
||||
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
|
||||
})
|
||||
|
||||
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
|
||||
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
|
||||
const substituted = (message: string) => ({
|
||||
error: { type: "server_error", message: `Upstream request failed: ${message}` },
|
||||
})
|
||||
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
|
||||
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
|
||||
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
|
||||
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
|
||||
[402, substituted("Insufficient account funds")],
|
||||
[402, substituted("Account invoice is overdue")],
|
||||
[429, substituted("Account budget exceeded")],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([status, body]) =>
|
||||
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
|
||||
}
|
||||
expect(
|
||||
[openai, anthropic].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "InvalidRequest"])
|
||||
// Without a contradicting status the same codes still mark provider trouble.
|
||||
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
|
||||
"ProviderInternal",
|
||||
)
|
||||
expect(
|
||||
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -62,7 +62,8 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
|
||||
if (url.pathname === "/api/info")
|
||||
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
|
||||
@@ -16,8 +16,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/status") {
|
||||
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
|
||||
if (url.pathname === "/api/info") {
|
||||
return json(route, {
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: [url.origin],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
}
|
||||
return json(route, {})
|
||||
})
|
||||
|
||||
@@ -138,7 +138,14 @@ test("MCP authentication starts before a slow resource catalog finishes", async
|
||||
test("multiple desktop connections show the session's server name", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.route("http://secondary.test/**", (route) =>
|
||||
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
|
||||
route.fulfill({
|
||||
json: {
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://secondary.test"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
|
||||
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/status")
|
||||
const response = await fetch("/api/info")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
for (const count of [7, 8]) {
|
||||
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
await page.route("**/api/project", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fulfill({ json: projectList(8) })
|
||||
})
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
|
||||
await page.reload()
|
||||
await requested
|
||||
const view = ui(page)
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
try {
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await expect(search).toHaveCount(0)
|
||||
} finally {
|
||||
inventory.resolve()
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
|
||||
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
|
||||
response.setHeader("cache-control", "no-store")
|
||||
if (path === "/observer.html")
|
||||
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
|
||||
if (path === "/api/status")
|
||||
if (path === "/api/info")
|
||||
return void response
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
|
||||
if (path === "/sw.js" && state.legacy && state.version === "old") {
|
||||
// Model the shipped worker's shared precache name and cache-first navigation behavior.
|
||||
const urls = Object.keys(builds.old).filter(
|
||||
@@ -334,8 +334,13 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
|
||||
|
||||
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
|
||||
await install(page, site.url)
|
||||
const api = await page.goto(`${site.url}/api/status`)
|
||||
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
|
||||
const api = await page.goto(`${site.url}/api/info`)
|
||||
expect(await api?.json()).toEqual({
|
||||
version: "test",
|
||||
pid: 1,
|
||||
urls: ["http://localhost"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
expect(api?.fromServiceWorker()).toBe(false)
|
||||
const asset = await page.goto(`${site.url}/_assets/missing.js`)
|
||||
expect(asset?.status()).toBe(404)
|
||||
|
||||
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
|
||||
}) {}
|
||||
|
||||
const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("event", "/api/event", {
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
|
||||
@@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
}),
|
||||
)
|
||||
.handleAll({
|
||||
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
|
||||
info: () =>
|
||||
Effect.succeed({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: config.server ? [config.server] : [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
}),
|
||||
config: () => Effect.succeed(configEntries),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { shouldHandlePasteAsAttachment } from "./interaction"
|
||||
|
||||
test("leaves paste to the browser when web clipboard data is unavailable", () => {
|
||||
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
|
||||
})
|
||||
|
||||
test("uses native image reading only when the clipboard has no text", () => {
|
||||
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
|
||||
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
|
||||
})
|
||||
|
||||
test("handles clipboard files as attachments", () => {
|
||||
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
|
||||
})
|
||||
|
||||
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
|
||||
return { types, items } as unknown as DataTransfer
|
||||
}
|
||||
@@ -384,20 +384,20 @@ export function createComposerEditor(input: {
|
||||
},
|
||||
onPaste(event: ClipboardEvent) {
|
||||
const clipboard = event.clipboardData
|
||||
if (
|
||||
attachments &&
|
||||
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
|
||||
) {
|
||||
const text = clipboard?.getData("text/plain")
|
||||
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
|
||||
void attachments.handlePaste(event)
|
||||
return
|
||||
}
|
||||
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
// insertText emits input events per line, repeatedly parsing and saving the draft.
|
||||
// Escaped HTML inserts multiline text once and preserves native selection and undo.
|
||||
const multiline = text.includes("\n")
|
||||
const value = multiline ? text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") : text
|
||||
const normalized = text.replace(/\r\n?/g, "\n")
|
||||
const multiline = normalized.includes("\n")
|
||||
const value = multiline
|
||||
? normalized.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
: normalized
|
||||
if (
|
||||
typeof document.execCommand === "function" &&
|
||||
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
|
||||
@@ -408,13 +408,13 @@ export function createComposerEditor(input: {
|
||||
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
|
||||
const range = selection.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
const node = document.createTextNode(text)
|
||||
const node = document.createTextNode(normalized)
|
||||
range.insertNode(node)
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
|
||||
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
|
||||
},
|
||||
onDragEnter(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
@@ -450,6 +450,12 @@ export function createComposerEditor(input: {
|
||||
|
||||
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
|
||||
|
||||
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
|
||||
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
|
||||
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
|
||||
return readClipboardImage
|
||||
}
|
||||
|
||||
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
if (inHistory) return position === 0 || position === text.length
|
||||
|
||||
@@ -15,6 +15,7 @@ export type {
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useGlobal } from "./runtime/server/runtime"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
import { WorkspaceSubmenu } from "@/workspaces/submenu"
|
||||
|
||||
export function PromptWorkspaceSelector(props: {
|
||||
value: string
|
||||
@@ -27,20 +28,11 @@ export function PromptWorkspaceSelector(props: {
|
||||
const placement = createMemo(() =>
|
||||
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
|
||||
)
|
||||
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
const [search, setSearch] = createStore({ branches: "" })
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
const branchTruncation = createTruncatedText()
|
||||
const focusWorktreeSearch = () =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
|
||||
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search.workspaces.trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "plus"
|
||||
@@ -51,7 +43,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setSearch({ workspaces: "", branches: "" })
|
||||
setSearch({ branches: "" })
|
||||
props.onSearch("")
|
||||
return
|
||||
}
|
||||
@@ -167,87 +159,12 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Menu.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={24}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
return
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
focusWorktreeSearch()
|
||||
}}
|
||||
>
|
||||
<Menu.SubTrigger
|
||||
onClick={focusWorktreeSearch}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
)
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
}}
|
||||
value={search.workspaces}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setSearch("workspaces", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
<Show when={search.workspaces.trim() && workspaces().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.SubContent>
|
||||
</Menu.Portal>
|
||||
</Menu.Sub>
|
||||
<WorkspaceSubmenu
|
||||
directories={props.workspaces}
|
||||
selected={selected()}
|
||||
onSelect={select}
|
||||
onViewAll={() => (pending = { type: "viewAll" })}
|
||||
/>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
|
||||
@@ -90,7 +90,12 @@ test("rotates HTTP and PTY clients together", async () => {
|
||||
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
|
||||
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
|
||||
return Response.json({
|
||||
version: "2.0.0-test",
|
||||
pid: 1,
|
||||
urls: [request.url],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
}) as typeof globalThis.fetch
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100", password: "first" },
|
||||
@@ -98,23 +103,23 @@ test("rotates HTTP and PTY clients together", async () => {
|
||||
})
|
||||
const initialPty = transport.pty
|
||||
|
||||
await transport.api.server.status()
|
||||
await transport.api.server.info()
|
||||
const replacement = transport.update({
|
||||
url: "http://127.0.0.1:4200",
|
||||
password: "second",
|
||||
})
|
||||
await transport.api.server.status()
|
||||
await transport.api.server.info()
|
||||
|
||||
expect(replacement).toBe(transport.api)
|
||||
expect(transport.pty).not.toBe(initialPty)
|
||||
expect(transport.url).toBe("http://127.0.0.1:4200")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://127.0.0.1:4100/api/status",
|
||||
url: "http://127.0.0.1:4100/api/info",
|
||||
authorization: `Basic ${btoa("opencode:first")}`,
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:4200/api/status",
|
||||
url: "http://127.0.0.1:4200/api/info",
|
||||
authorization: `Basic ${btoa("opencode:second")}`,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createStore } from "solid-js/store"
|
||||
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { createServerTransport } from "@/runtime/server/client"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
@@ -64,6 +65,44 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
}
|
||||
})
|
||||
|
||||
// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an
|
||||
// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through.
|
||||
test("recovers project metadata after the connection to the server is dropped", async () => {
|
||||
const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
let dropped = 0
|
||||
const requests: string[] = []
|
||||
const server = Bun.listen({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
socket: {
|
||||
open(socket) {
|
||||
if (dropped >= 2) return
|
||||
dropped += 1
|
||||
socket.terminate()
|
||||
},
|
||||
data(socket, chunk) {
|
||||
requests.push(String(chunk).split(" ")[0] ?? "")
|
||||
socket.end(
|
||||
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\naccess-control-allow-origin: *\r\nconnection: close\r\n\r\n${body}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } })
|
||||
|
||||
try {
|
||||
const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery(
|
||||
loadProjectsQuery(ServerScope.local, transport.api.project),
|
||||
)
|
||||
expect(dropped).toBe(2)
|
||||
// happy-dom's fetch adds a CORS preflight; only the GET is the retried API call.
|
||||
expect(requests.filter((method) => method === "GET")).toHaveLength(1)
|
||||
expect(result).toMatchObject([{ id: "project", worktree: "/repo" }])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const location = {} as ServerApi["location"]
|
||||
|
||||
@@ -17,7 +17,12 @@ describe("checkServerHealth", () => {
|
||||
const headers: Array<string | null> = []
|
||||
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
headers.push(new Headers(init?.headers).get("authorization"))
|
||||
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
|
||||
return Response.json({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: [server.url],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
|
||||
@@ -28,16 +33,19 @@ describe("checkServerHealth", () => {
|
||||
let request: URL | undefined
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth(server, fetch)
|
||||
|
||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||
expect(request?.pathname).toBe("/api/status")
|
||||
expect(request?.pathname).toBe("/api/info")
|
||||
})
|
||||
|
||||
test("allows slow servers thirty seconds by default", async () => {
|
||||
@@ -52,7 +60,7 @@ describe("checkServerHealth", () => {
|
||||
})
|
||||
|
||||
const fetch = (async () =>
|
||||
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
@@ -111,10 +119,13 @@ describe("checkServerHealth", () => {
|
||||
let signal: AbortSignal | undefined
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
signal = abortFromInput(input, init)
|
||||
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const abort = new AbortController()
|
||||
@@ -130,10 +141,13 @@ describe("checkServerHealth", () => {
|
||||
const fetch = (async () => {
|
||||
count += 1
|
||||
if (count < 3) throw new TypeError("network")
|
||||
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth(server, fetch, {
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function checkServerHealth(
|
||||
fetch,
|
||||
headers,
|
||||
})
|
||||
.server.status({ signal })
|
||||
.server.info({ signal })
|
||||
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current) return current.data
|
||||
|
||||
@@ -8,11 +8,8 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
|
||||
import type { SshItem } from "@/servers/ssh/types"
|
||||
|
||||
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
|
||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||
// against the live project list (dropping deleted projects) and then cap the visible count via
|
||||
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
||||
// filtered out do not evict still-visible ones from the persisted store.
|
||||
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
||||
// Retain closed paths until reopened so settings can exclude them from the server inventory.
|
||||
// The Home page independently limits the visible recently closed entries.
|
||||
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
@@ -51,6 +48,7 @@ export function createServerProjects(input: {
|
||||
}
|
||||
return {
|
||||
list: current,
|
||||
closed: currentClosed,
|
||||
recentlyClosed: currentClosed,
|
||||
remove,
|
||||
open(directory: string) {
|
||||
@@ -72,10 +70,7 @@ export function createServerProjects(input: {
|
||||
close(directory: string) {
|
||||
remove(directory)
|
||||
const key = pathKey(directory)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||
0,
|
||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||
)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
|
||||
setStore("recentlyClosed", input.scope(), closed)
|
||||
},
|
||||
expand(directory: string) {
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
|
||||
input.tick(100)
|
||||
input.queue.fetch("http://server/api/status").catch(() => undefined)
|
||||
input.queue.fetch("http://server/api/info").catch(() => undefined)
|
||||
expect(input.logs).toEqual([])
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
|
||||
],
|
||||
queued: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
|
||||
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
|
||||
{ method: "GET", url: "http://server/api/info", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -146,7 +146,11 @@ function createServerController(
|
||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||
// Without this, different subdirectories of the same git repo would share the same
|
||||
// icon from the database instead of using their individual overrides.
|
||||
const base = { ...metadata, ...project }
|
||||
const base = {
|
||||
...metadata,
|
||||
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
|
||||
...project,
|
||||
}
|
||||
if (childStore.icon) {
|
||||
return { ...base, icon: { ...base.icon, override: childStore.icon } }
|
||||
}
|
||||
@@ -174,6 +178,7 @@ function createServerController(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
resolve: enrich,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
notification,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor, type JSX } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
@@ -213,15 +213,16 @@ export function createActiveSessionRegion(input: {
|
||||
|
||||
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
|
||||
|
||||
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
|
||||
export function ActiveSessionComposerRegion(props: { model: SessionComposerController; footer?: JSX.Element }) {
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={props.model.region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<div class="relative z-10 rounded-xl border border-v2-border-border-base bg-v2-background-bg-deep">
|
||||
<Composer model={props.model.composer} borderUnderlay />
|
||||
{props.footer}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
|
||||
|
||||
export function SessionWorkspaceFooter(props: {
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
move?: { project: Project; sessionID: string; eligible: boolean }
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const label = () => (
|
||||
<>
|
||||
<Icon
|
||||
name={props.local ? "monitor" : "outline-worktree"}
|
||||
size="small"
|
||||
class={props.local ? "shrink-0 text-v2-icon-icon-muted" : "shrink-0 text-v2-icon-icon-accent"}
|
||||
/>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{props.local
|
||||
? language.t("session.new.workspace.triggerLocal")
|
||||
: getFilename(
|
||||
(props.move &&
|
||||
workspaceDirectories(props.move.project).find((directory) => containsDirectory(directory, props.directory))) ??
|
||||
props.directory,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-workspace-footer" class="w-full shrink-0 rounded-b-xl bg-v2-background-bg-deep">
|
||||
<div
|
||||
class="flex h-9 w-full min-w-0 items-center gap-2 px-2.5 text-[12px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
|
||||
>
|
||||
<div class="min-w-0 max-w-[203px]" title={props.directory}>
|
||||
<Show when={props.move} fallback={<div class="flex h-6 min-w-0 items-center gap-1 px-1.5">{label()}</div>}>
|
||||
{(move) => (
|
||||
<SessionWorkspaceMenu
|
||||
project={move().project}
|
||||
sessionID={move().sessionID}
|
||||
eligible={move().eligible}
|
||||
directory={props.directory}
|
||||
placement="top-start"
|
||||
class="flex h-6 min-w-0 max-w-full items-center gap-1 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed disabled:opacity-50"
|
||||
>
|
||||
{label()}
|
||||
<Icon name="chevron-down" size="small" class="size-3 shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.branch}>
|
||||
{(branch) => (
|
||||
<div
|
||||
class="flex h-5 min-w-0 max-w-[220px] items-center gap-1 rounded-full bg-v2-background-bg-layer-02 px-2"
|
||||
title={branch()}
|
||||
>
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { createTimelineSearchController } from "./timeline/search-controller"
|
||||
import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionWorkspaceFooter } from "./composer/workspace-footer"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
@@ -182,6 +183,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
visible: conversationVisible,
|
||||
})
|
||||
useUsageExceededDialogs()
|
||||
const workspaceMove = createMemo(() => {
|
||||
const project = detailsProject()
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!project || !sessionID) return
|
||||
return { project, sessionID, eligible: composer.workspaceMoveEligible() }
|
||||
})
|
||||
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
createEffect(on(session.identity.sessionKey, reset, { defer: true }))
|
||||
@@ -325,7 +332,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => <ActiveSessionComposerRegion model={model} />}
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion
|
||||
model={model}
|
||||
footer={
|
||||
<SessionWorkspaceFooter
|
||||
move={workspaceMove()}
|
||||
directory={session.workspace.directory()}
|
||||
local={!session.workspace.current()}
|
||||
branch={session.shared.data.location.vcs.info({ directory: session.workspace.directory() })?.branch.current}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SessionComposerRegionViewController,
|
||||
} from "@/session/composer/session-composer-region"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { SessionWorkspaceFooter } from "@/session/composer/workspace-footer"
|
||||
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode/client/promise"
|
||||
import type { SessionDocument } from "@opencode/session-ui/document"
|
||||
import { CurrentSessionProviders, STORY_MODEL } from "@opencode/session-ui/storybook"
|
||||
@@ -266,7 +267,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
</div>
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
composer={<Composer model={prompt.controller} borderUnderlay />}
|
||||
composer={
|
||||
<div class="rounded-xl border border-v2-border-border-base bg-v2-background-bg-deep">
|
||||
<Composer model={prompt.controller} borderUnderlay />
|
||||
<SessionWorkspaceFooter directory="/workspace/opencode" local branch="modular-session-ui" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<Show when={state.reviewOpened}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSignal, For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { createSignal, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -11,6 +10,7 @@ import { pathKey } from "@/workspaces/path-key"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
import { WorkspaceSubmenu } from "@/workspaces/submenu"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible?: boolean
|
||||
@@ -112,24 +112,11 @@ export function SessionWorkspaceMenu(props: {
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={24}>
|
||||
<Menu.SubTrigger>
|
||||
<Icon name="outline-worktree" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.SubContent>
|
||||
</Menu.Portal>
|
||||
</Menu.Sub>
|
||||
<WorkspaceSubmenu
|
||||
directories={workspaces()}
|
||||
disabled={!!store.selected || blocked()}
|
||||
onSelect={(directory) => void move(directory)}
|
||||
/>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
|
||||
@@ -6,12 +6,19 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
export function SettingsSearchEmpty(props: { query: string }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ query: props.query })
|
||||
let quoted: HTMLSpanElement | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
let measure: HTMLSpanElement | undefined
|
||||
const text = (query: string) => language.t("settings.search.empty.query", { query })
|
||||
const update = () => {
|
||||
if (!quoted || !measure) return
|
||||
const width = quoted.getBoundingClientRect().width
|
||||
if (!container || !measure) return
|
||||
const style = getComputedStyle(container)
|
||||
measure.textContent = language.t("settings.search.empty", { query: "" })
|
||||
// Measure the available query space independently of its current truncated text.
|
||||
const width =
|
||||
container.clientWidth -
|
||||
parseFloat(style.paddingInlineStart) -
|
||||
parseFloat(style.paddingInlineEnd) -
|
||||
measure.getBoundingClientRect().width
|
||||
const fits = (query: string) => {
|
||||
measure!.textContent = text(query)
|
||||
return measure!.getBoundingClientRect().width <= width
|
||||
@@ -40,21 +47,22 @@ export function SettingsSearchEmpty(props: { query: string }) {
|
||||
|
||||
createEffect(update)
|
||||
onMount(() => {
|
||||
if (!quoted || !measure) return
|
||||
if (!container || !measure) return
|
||||
// The measuring text also observes font changes that do not resize the available space.
|
||||
createResizeObserver([quoted, measure], update)
|
||||
createResizeObserver([container, measure], update)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={container}
|
||||
class="settings-search-empty"
|
||||
role="status"
|
||||
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
|
||||
>
|
||||
{language.rich("settings.search.empty", {
|
||||
query: (
|
||||
<span ref={quoted} class="settings-search-empty-quoted">
|
||||
<span class="settings-search-empty-quoted">
|
||||
<bdi dir="auto">{text(state.query)}</bdi>
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
color: var(--v2-icon-icon-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-search
|
||||
:is(.settings-search, .settings-projects-search)
|
||||
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-base);
|
||||
@@ -37,19 +38,20 @@
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
|
||||
--mask-start: 16px;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
|
||||
--mask-end: 16px;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
|
||||
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
|
||||
--mask-direction: to left;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
:is(.settings-search, .settings-projects-search)
|
||||
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
|
||||
mask-image: linear-gradient(
|
||||
var(--mask-direction, to right),
|
||||
transparent,
|
||||
|
||||
@@ -7,14 +7,18 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
|
||||
import type { ServerCtx } from "@/runtime/server/runtime"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
|
||||
export function settingsProjects(context: ServerCtx) {
|
||||
export function settingsProjects(context: {
|
||||
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
|
||||
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
|
||||
}) {
|
||||
const tracked = context.projects.list()
|
||||
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
|
||||
const closed = new Set(context.projects.closed().map(pathKey))
|
||||
return [
|
||||
...tracked,
|
||||
...context.sync.data.project
|
||||
.filter((project) => !paths.has(pathKey(project.worktree)))
|
||||
.map((project) => ({ ...project, expanded: false })),
|
||||
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
|
||||
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -803,6 +803,78 @@
|
||||
padding-inline-end: 28px;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-component="text-input-v2"] {
|
||||
height: 36px;
|
||||
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-input"] {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-inline-start: 16px;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-value"] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
|
||||
margin-inline-end: -4px;
|
||||
}
|
||||
|
||||
.settings-projects-empty {
|
||||
width: 60%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.settings-projects-empty .settings-search-empty {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.settings-projects-empty .settings-search-empty-quoted {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.settings-project-row {
|
||||
position: relative;
|
||||
margin-inline: 1px;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
|
||||
.settings-project-row:hover {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-project-row-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 73px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
border-radius: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.settings-project-row-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -294,8 +294,6 @@ function RootSettings() {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
autofocus={!surface.search.state.selected}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: ServerConnection.key(server),
|
||||
@@ -378,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: props.entry.key,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { InlineInput } from "@opencode/ui/inline-input"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { displayName, errorMessage } from "@/shell/layout/helpers"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
|
||||
export function SettingsProjectRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
onOpen: (project: LocalProject) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const [store, setStore] = createStore({
|
||||
menu: undefined as { x: number; y: number } | undefined,
|
||||
editor: undefined as { draft: string; saving: boolean } | undefined,
|
||||
})
|
||||
let row: HTMLDivElement | undefined
|
||||
let button: HTMLButtonElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let outside = false
|
||||
const openMenu = (x: number, y: number) => {
|
||||
if (!row) return
|
||||
const bounds = row.getBoundingClientRect()
|
||||
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
|
||||
}
|
||||
const openEditor = () => {
|
||||
setStore("editor", { draft: displayName(props.project), saving: false })
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
const closeEditor = () => {
|
||||
if (store.editor?.saving) return
|
||||
setStore("editor", undefined)
|
||||
}
|
||||
const saveEditor = async () => {
|
||||
if (!store.editor || store.editor.saving) return
|
||||
const name = store.editor.draft.trim()
|
||||
if (!name || name === displayName(props.project)) {
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => button?.focus())
|
||||
return
|
||||
}
|
||||
setStore("editor", "saving", true)
|
||||
const context = global.ensureServerCtx(props.server)
|
||||
const value = name === getFilename(props.project.worktree) ? "" : name
|
||||
const saved = await (props.project.id && props.project.id !== "global"
|
||||
? context.sdk.api.project
|
||||
.update({ projectID: props.project.id, name: value })
|
||||
.then((project) => context.sync.project.update(project))
|
||||
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
|
||||
)
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
const restore = document.activeElement === document.body || document.activeElement === input
|
||||
if (saved) setStore("editor", undefined)
|
||||
if (!saved) setStore("editor", "saving", false)
|
||||
if (!restore) return
|
||||
requestAnimationFrame(() => (saved ? button : input)?.focus())
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={row}
|
||||
data-component="settings-project-row"
|
||||
class="settings-project-row group"
|
||||
onContextMenu={(event) => {
|
||||
if (store.editor) return
|
||||
event.preventDefault()
|
||||
openMenu(event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!store.editor}
|
||||
fallback={
|
||||
<div class="settings-project-row-content">
|
||||
<ProjectRowContent project={props.project}>
|
||||
<InlineInput
|
||||
ref={input}
|
||||
aria-label={language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={store.editor?.draft ?? ""}
|
||||
disabled={store.editor?.saving}
|
||||
class="settings-project-row-name w-full outline-none"
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => button?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
</ProjectRowContent>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!store.menu}
|
||||
class="settings-project-row-content"
|
||||
onClick={() => props.onOpen(props.project)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
openMenu(bounds.left + 12, bounds.bottom)
|
||||
}}
|
||||
>
|
||||
<ProjectRowContent project={props.project}>
|
||||
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
|
||||
</ProjectRowContent>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
modal={false}
|
||||
placement="bottom-start"
|
||||
gutter={2}
|
||||
open={!!store.menu}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setStore("menu", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as="span"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
class="pointer-events-none absolute size-px"
|
||||
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onInteractOutside={() => {
|
||||
outside = true
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const restore = !outside && !store.editor
|
||||
outside = false
|
||||
if (restore) requestAnimationFrame(() => button?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
|
||||
<Show
|
||||
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
if (!platform.openPath) return
|
||||
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
const next = row?.nextElementSibling ?? row?.previousElementSibling
|
||||
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
|
||||
requestAnimationFrame(() => next?.querySelector("button")?.focus())
|
||||
}}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
|
||||
return (
|
||||
<span class="flex items-start gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{props.children}
|
||||
<bdi
|
||||
dir="ltr"
|
||||
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
|
||||
title={props.project.worktree}
|
||||
>
|
||||
{props.project.worktree}
|
||||
</bdi>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,43 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
|
||||
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { SettingsSearchEmpty } from "../search-empty"
|
||||
import { settingsProjects } from "../servers/inventory"
|
||||
import { SettingsProjectRow } from "./project-row"
|
||||
import "@/settings/search.css"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsProjects: Component<{
|
||||
server: ServerConnection.Any
|
||||
active?: boolean
|
||||
autofocus?: boolean
|
||||
onOpenProject: (project: LocalProject) => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
|
||||
let search: HTMLInputElement | undefined
|
||||
const updateOverflow = () => {
|
||||
if (!search) return
|
||||
const offset = Math.abs(search.scrollLeft)
|
||||
setStore("overflow", {
|
||||
start: offset > 1,
|
||||
end: search.scrollWidth - search.clientWidth - offset > 1,
|
||||
})
|
||||
}
|
||||
createEffect(on(() => store.filter, updateOverflow))
|
||||
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
|
||||
const searchable = createMemo(() => projects().length > 7)
|
||||
const filtered = createMemo(() => {
|
||||
const query = searchable() ? store.filter.trim().toLowerCase() : ""
|
||||
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => (props.active ?? true) && searchable(),
|
||||
(active) => {
|
||||
if (!active) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.active !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!searchable()) setStore("filter", "")
|
||||
})
|
||||
@@ -54,16 +52,24 @@ export const SettingsProjects: Component<{
|
||||
</div>
|
||||
</div>
|
||||
<Show when={searchable()}>
|
||||
<div class="settings-tab-search">
|
||||
<div class="settings-tab-search settings-projects-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
ref={(element) => {
|
||||
search = element
|
||||
createResizeObserver(element, updateOverflow)
|
||||
}}
|
||||
type="search"
|
||||
appearance="base"
|
||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||
value={store.filter}
|
||||
data-overflow-start={store.overflow.start}
|
||||
data-overflow-end={store.overflow.end}
|
||||
onScroll={updateOverflow}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.projects.search.placeholder")}
|
||||
aria-label={language.t("settings.projects.search.placeholder")}
|
||||
showClearButton={!!store.filter}
|
||||
clearIcon="circle-xmark"
|
||||
onClearClick={() => {
|
||||
setStore("filter", "")
|
||||
search?.focus({ preventScroll: true })
|
||||
@@ -81,32 +87,26 @@ export const SettingsProjects: Component<{
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
<Show
|
||||
when={store.filter.trim()}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="settings-projects-empty">
|
||||
<SettingsSearchEmpty query={store.filter} />
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<For each={filtered()}>
|
||||
<Key each={filtered()} by="worktree">
|
||||
{(project) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={displayName(project)}
|
||||
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
|
||||
onClick={() => props.onOpenProject(project)}
|
||||
>
|
||||
<span class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={project} class="shrink-0" />
|
||||
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
|
||||
)}
|
||||
</For>
|
||||
</Key>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -218,22 +218,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 64px) {
|
||||
@container (44px < width <= 64px) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
|
||||
[data-slot="tab-link"] {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):is(:hover, [data-active="true"])
|
||||
[data-titlebar-tab-title] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 44px) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-orientation="vertical"]):not([data-editing="true"]):dir(
|
||||
rtl
|
||||
)
|
||||
[data-slot="tab-link"] {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
|
||||
@@ -244,6 +252,13 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not(:hover):not(:has(:focus-visible))
|
||||
[data-slot="tab-close"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-close"] {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function WorkspaceSubmenu(props: {
|
||||
directories: string[]
|
||||
selected?: string
|
||||
disabled?: boolean
|
||||
onSelect: (directory: string) => void
|
||||
onViewAll?: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ search: "", focusSearch: false })
|
||||
let input: HTMLInputElement | undefined
|
||||
let list: HTMLDivElement | undefined
|
||||
const searchable = () => props.directories.length >= 10
|
||||
const directories = createMemo(() => {
|
||||
const query = store.search.trim().toLowerCase()
|
||||
return props.directories.filter((directory) => getFilename(directory).toLowerCase().includes(query))
|
||||
})
|
||||
const focusSearch = () => {
|
||||
if (!searchable()) return
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={24}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setStore({ search: "", focusSearch: false })
|
||||
return
|
||||
}
|
||||
if (store.focusSearch) focusSearch()
|
||||
setStore("focusSearch", false)
|
||||
}}
|
||||
>
|
||||
<Menu.SubTrigger
|
||||
onClick={focusSearch}
|
||||
onKeyDown={(event) => {
|
||||
if (["ArrowRight", "ArrowLeft", "Enter", " "].includes(event.key)) setStore("focusSearch", true)
|
||||
}}
|
||||
>
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent
|
||||
data-slot="workspace-submenu"
|
||||
class="max-h-[min(320px,calc(100dvh-48px))] w-[200px] overflow-hidden"
|
||||
>
|
||||
<Show when={searchable()}>
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={input}
|
||||
value={store.search}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setStore("search", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const items = list?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
||||
items?.[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus()
|
||||
return
|
||||
}
|
||||
if (["Escape", "Enter"].includes(event.key)) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={list} data-slot="workspace-submenu-list" class="min-h-0 overflow-y-auto overscroll-contain">
|
||||
<For each={directories()}>
|
||||
{(directory) => (
|
||||
<Menu.Item disabled={props.disabled} onSelect={() => props.onSelect(directory)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(directory)}</span>
|
||||
<Show when={props.selected === directory}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
<Show when={store.search.trim() && directories().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.onViewAll}>
|
||||
<Menu.Separator class="h-[0.5px] shrink-0" />
|
||||
<Menu.Item onSelect={() => props.onViewAll?.()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.SubContent>
|
||||
</Menu.Portal>
|
||||
</Menu.Sub>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServerStatus } from "@opencode/protocol/groups/server"
|
||||
import { ServerInfo } from "@opencode/protocol/groups/server"
|
||||
import { Effect, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
@@ -42,12 +42,12 @@ try {
|
||||
const credential = btoa(`opencode:${info.password}`)
|
||||
const headers = { authorization: "Basic " + credential }
|
||||
const token = encodeURIComponent(credential)
|
||||
const status = await waitForReady(info.url, headers)
|
||||
if (status.pid !== info.pid) throw new Error("Status process does not match registration")
|
||||
const tokenStatus = await fetch(new URL(`/api/status?auth_token=${token}`, info.url), {
|
||||
const serverInfo = await waitForReady(info.url, headers)
|
||||
if (serverInfo.pid !== info.pid) throw new Error("Server info does not match registration")
|
||||
const tokenInfo = await fetch(new URL(`/api/info?auth_token=${token}`, info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (tokenStatus.status !== 200) throw new Error("Compiled service rejected query authentication")
|
||||
if (tokenInfo.status !== 200) throw new Error("Compiled service rejected query authentication")
|
||||
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
@@ -57,10 +57,10 @@ try {
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
|
||||
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
|
||||
if (unauthorizedInfo.status !== 401) throw new Error("Compiled service exposed info without authentication")
|
||||
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
@@ -128,11 +128,11 @@ async function waitForRegistration() {
|
||||
async function waitForReady(url: string, headers: HeadersInit) {
|
||||
const deadline = Date.now() + 20_000
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(new URL("/api/status", url), {
|
||||
const response = await fetch(new URL("/api/info", url), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
}).catch(() => undefined)
|
||||
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
|
||||
if (response?.ok) return Schema.decodeUnknownPromise(ServerInfo)(await response.json())
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not become ready")
|
||||
|
||||
@@ -15,7 +15,7 @@ export default Runtime.handler(
|
||||
const urls = Option.isSome(input.url)
|
||||
? [input.url.value]
|
||||
: (yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
process.stdout.write(
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const empty: Info = {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const file = path.join(global.config, "cli.json")
|
||||
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
|
||||
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
|
||||
: undefined
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
return parseRecord(text)
|
||||
})
|
||||
|
||||
const write = Effect.fnUntraced(function* (text: string) {
|
||||
@@ -61,6 +61,9 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
const load = Effect.fnUntraced(function* (migration?: Info) {
|
||||
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
|
||||
})
|
||||
|
||||
const get = Effect.fn("cli.config.get")(() =>
|
||||
withLock(
|
||||
@@ -72,8 +75,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (migration?.cause)
|
||||
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
|
||||
if (migration?.info) return migration.info
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
return yield* load(migration?.info)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -83,7 +85,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const migration = yield* migrate
|
||||
if (migration?.cause) return yield* Effect.failCause(migration.cause)
|
||||
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const current = yield* load(migration?.info)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
@@ -102,7 +104,7 @@ export const layer = Layer.effect(
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
return merge(config, content)
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
)
|
||||
@@ -113,6 +115,39 @@ export const layer = Layer.effect(
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||
|
||||
function merge(...values: readonly (Info | undefined)[]) {
|
||||
return Option.getOrElse(
|
||||
decode(
|
||||
values.reduce<Record<string, unknown>>(
|
||||
(result, value) => mergeRecords(result, value ?? {}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
() => empty,
|
||||
)
|
||||
}
|
||||
|
||||
function mergeRecords(base: object, overlay: object) {
|
||||
return Object.entries(overlay).reduce<Record<string, unknown>>(
|
||||
(result, [key, value]) => {
|
||||
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
|
||||
return result
|
||||
},
|
||||
{ ...base },
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseRecord(text: string) {
|
||||
const errors: ParseError[] = []
|
||||
const value: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
}
|
||||
|
||||
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
|
||||
@@ -29,7 +29,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
} satisfies Endpoint
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const health = yield* Effect.tryPromise({
|
||||
try: () => client.server.status({ signal: AbortSignal.timeout(5_000) }),
|
||||
try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => connectError(endpoint, cause),
|
||||
})
|
||||
if (health.version !== OPENCODE_VERSION)
|
||||
|
||||
@@ -272,7 +272,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests?.push(url.pathname)
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/info") return status()
|
||||
if (url.pathname === "/api/model/default") return Response.json(located(null))
|
||||
return fetch(request, url)
|
||||
},
|
||||
@@ -280,7 +280,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
|
||||
}
|
||||
|
||||
function status() {
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
|
||||
}
|
||||
|
||||
function located<T>(data: T) {
|
||||
|
||||
@@ -71,6 +71,51 @@ test("preserves the schema in an existing cli.json", async () => {
|
||||
expect(await Bun.file(file).json()).toEqual(config)
|
||||
})
|
||||
|
||||
test("merges inline CLI config content over the global config", async () => {
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
const previous = process.env.OPENCODE_CLI_CONFIG_CONTENT
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tabs: { enabled: true, scope: "global" },
|
||||
keybinds: { "app.exit": "ctrl+q" },
|
||||
plugins: ["global"],
|
||||
animations: true,
|
||||
}),
|
||||
)
|
||||
process.env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
|
||||
tabs: { enabled: false },
|
||||
keybinds: { "help.show": false },
|
||||
plugins: ["inline"],
|
||||
animations: false,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
const loaded = yield* service.get()
|
||||
const updated = yield* service.update((draft) => {
|
||||
draft.animations = true
|
||||
draft.mouse = false
|
||||
})
|
||||
return { loaded, updated }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
|
||||
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
|
||||
expect(result.loaded.plugins).toEqual(["inline"])
|
||||
expect(result.updated).toMatchObject({ animations: false, mouse: false })
|
||||
expect(await Bun.file(file).json()).toMatchObject({ animations: true, mouse: false })
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CLI_CONFIG_CONTENT
|
||||
else process.env.OPENCODE_CLI_CONFIG_CONTENT = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(
|
||||
|
||||
@@ -39,9 +39,14 @@ describe("debug config command", () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/status") {
|
||||
if (url.pathname === "/api/info") {
|
||||
healthProbes += 1
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
return Response.json({
|
||||
version: OPENCODE_VERSION,
|
||||
pid: process.pid,
|
||||
urls: [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
}
|
||||
requested = url
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
|
||||
@@ -4,6 +4,7 @@ export function isolatedEnv(root: string, overrides: Record<string, string | und
|
||||
return {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCODE_CLI_CONFIG_CONTENT: undefined,
|
||||
OPENCODE_CONFIG_CONTENT: "{}",
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"),
|
||||
OPENCODE_DB: path.join(root, "opencode.db"),
|
||||
|
||||
@@ -12,7 +12,7 @@ await Effect.runPromise(
|
||||
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/status", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
)
|
||||
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
|
||||
return yield* Effect.never
|
||||
|
||||
@@ -42,7 +42,8 @@ const sanitizedTransfer = {
|
||||
],
|
||||
}
|
||||
|
||||
const status = () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
const status = () =>
|
||||
Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
|
||||
|
||||
function run(args: string[], stdin?: string) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
@@ -60,7 +61,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/info") return status()
|
||||
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
|
||||
if (url.pathname === `/api/experimental/session/${info.id}/export`) {
|
||||
sanitization.push(url.searchParams.get("sanitize") ?? "")
|
||||
@@ -98,7 +99,7 @@ test("export requires a session outside an interactive terminal", async () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/info") return status()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: "/project",
|
||||
@@ -127,7 +128,7 @@ test("export reports a missing session without a stack trace", async () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/info") return status()
|
||||
if (url.pathname === `/api/experimental/session/${sessionID}/export`) {
|
||||
return Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
|
||||
@@ -158,7 +159,7 @@ test("import validates a file and sends it to the resolved location", async () =
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/info") return status()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: root,
|
||||
@@ -201,7 +202,7 @@ test("import reports an existing session without a stack trace", async () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/info") return status()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: root,
|
||||
|
||||
@@ -31,14 +31,24 @@ describe("mini command", () => {
|
||||
const initial = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
return Response.json({
|
||||
version: OPENCODE_VERSION,
|
||||
pid: process.pid,
|
||||
urls: [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const replacement = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
return Response.json({
|
||||
version: OPENCODE_VERSION,
|
||||
pid: process.pid,
|
||||
urls: [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
@@ -57,7 +67,7 @@ describe("mini command", () => {
|
||||
})
|
||||
const client = await connection.reconnect?.(controller.signal)
|
||||
if (!client) throw new Error("Expected a replacement client")
|
||||
await client.server.status()
|
||||
await client.server.info()
|
||||
|
||||
expect(client).not.toBe(connection.sdk)
|
||||
expect(signal).toBe(controller.signal)
|
||||
@@ -147,8 +157,13 @@ describe("mini command", () => {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/api/status")
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
if (url.pathname === "/api/info")
|
||||
return Response.json({
|
||||
version: OPENCODE_VERSION,
|
||||
pid: process.pid,
|
||||
urls: [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/session") {
|
||||
@@ -190,7 +205,12 @@ describe("mini command", () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
|
||||
return Response.json({ version: "incompatible", pid: process.pid, urls: [] })
|
||||
return Response.json({
|
||||
version: "incompatible",
|
||||
pid: process.pid,
|
||||
urls: [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -256,13 +256,14 @@ test("concurrent service processes elect one server", async () => {
|
||||
expect((await Bun.file(config).json()).password).toBe(info.password)
|
||||
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
|
||||
expect(
|
||||
await fetch(new URL("/api/status", info.url), {
|
||||
await fetch(new URL("/api/info", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
}).then((response) => response.json()),
|
||||
).toEqual({
|
||||
version: info.version,
|
||||
pid: info.pid,
|
||||
urls: [info.url],
|
||||
paths: { tmp: path.join(os.tmpdir(), "opencode") },
|
||||
})
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
try {
|
||||
@@ -337,7 +338,7 @@ test.each([
|
||||
const info = await waitForInfo(registration)
|
||||
await Promise.all(
|
||||
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
|
||||
const response = await fetch(new URL("/api/status", info.url), {
|
||||
const response = await fetch(new URL("/api/info", info.url), {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
|
||||
})
|
||||
@@ -440,7 +441,10 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||
fetch() {
|
||||
requests.count += 1
|
||||
if (requests.count === 2) recognizing.resolve()
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }, { status: 503 })
|
||||
return Response.json(
|
||||
{ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } },
|
||||
{ status: 503 },
|
||||
)
|
||||
},
|
||||
})
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
@@ -575,7 +579,7 @@ async function waitForInfo(file: string, accept: (info: Info) => boolean = () =>
|
||||
|
||||
async function waitForFailed(info: Info) {
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
const status = await fetch(new URL("/api/status", info.url), {
|
||||
const status = await fetch(new URL("/api/info", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
})
|
||||
.then((response) => response.status)
|
||||
|
||||
@@ -35,8 +35,13 @@ describe("web UI", () => {
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const pathname = new URL(request.url, "http://localhost").pathname
|
||||
if (pathname === "/api/status")
|
||||
return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin] })
|
||||
if (pathname === "/api/info")
|
||||
return HttpServerResponse.jsonUnsafe({
|
||||
version: "test",
|
||||
pid: 1,
|
||||
urls: [origin],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
return yield* Effect.fail(
|
||||
new HttpServerError.HttpServerError({
|
||||
reason: new HttpServerError.RouteNotFound({ request }),
|
||||
@@ -47,8 +52,13 @@ describe("web UI", () => {
|
||||
)
|
||||
const origin = HttpServer.formatAddress(http.address)
|
||||
|
||||
const status = yield* Effect.promise(() => fetch(`${origin}/api/status`))
|
||||
expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin] })
|
||||
const status = yield* Effect.promise(() => fetch(`${origin}/api/info`))
|
||||
expect(yield* Effect.promise(() => status.json())).toEqual({
|
||||
version: "test",
|
||||
pid: 1,
|
||||
urls: [origin],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
|
||||
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
|
||||
expect(missing.status).toBe(404)
|
||||
|
||||
@@ -39,23 +39,28 @@ import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
export type ServerStatusOutput = {
|
||||
export type ServerInfoOutput = {
|
||||
readonly version: string
|
||||
readonly pid: number
|
||||
readonly urls: ReadonlyArray<string>
|
||||
readonly paths: { readonly tmp: string }
|
||||
}
|
||||
export type ServerStatusOperation<E = never> = () => Effect.Effect<ServerStatusOutput, E>
|
||||
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
|
||||
|
||||
export interface ServerApi<E = never> {
|
||||
readonly status: ServerStatusOperation<E>
|
||||
readonly info: ServerInfoOperation<E>
|
||||
}
|
||||
|
||||
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type LocationGetOutput = Location.PublicInfo
|
||||
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
|
||||
|
||||
export type LocationReloadOutput = void
|
||||
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
|
||||
|
||||
export interface LocationApi<E = never> {
|
||||
readonly get: LocationGetOperation<E>
|
||||
readonly reload: LocationReloadOperation<E>
|
||||
}
|
||||
|
||||
export type AgentListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
|
||||
@@ -5,9 +5,10 @@ import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
ServerStatusOutput,
|
||||
ServerInfoOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentGetInput,
|
||||
@@ -277,17 +278,23 @@ const preserveStream =
|
||||
<E, R>(stream: Stream.Stream<A, E, R>) =>
|
||||
stream
|
||||
|
||||
const EndpointServerStatus = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerStatusOutput>()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ status: EndpointServerStatus(raw) })
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
|
||||
|
||||
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
|
||||
preserveEffect<LocationGetOutput>()(
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({ get: EndpointLocationGet(raw) })
|
||||
const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
|
||||
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
|
||||
get: EndpointLocationGet(raw),
|
||||
reload: EndpointLocationReload(raw),
|
||||
})
|
||||
|
||||
const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentListInput) =>
|
||||
preserveEffect<AgentListOutput>()(
|
||||
|
||||
@@ -172,7 +172,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeStatus = Schema.decodeUnknownOption(
|
||||
const decodeInfo = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
@@ -213,7 +213,7 @@ const probeResult = Effect.fnUntraced(function* (
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
|
||||
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
@@ -225,7 +225,7 @@ const probeResult = Effect.fnUntraced(function* (
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
|
||||
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
|
||||
// to recognize the registered daemon as incompatible and route it through replacement.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
@@ -239,16 +239,16 @@ const probeResult = Effect.fnUntraced(function* (
|
||||
timedOut: false,
|
||||
}
|
||||
const body = result.value.body
|
||||
const status = decodeStatus(body)
|
||||
if (Option.isSome(status)) {
|
||||
if (status.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && status.value.version !== info.version)
|
||||
const serverInfo = decodeInfo(body)
|
||||
if (Option.isSome(serverInfo)) {
|
||||
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: status.value.version,
|
||||
version: serverInfo.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
ServerStatusOutput,
|
||||
ServerInfoOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentGetInput,
|
||||
@@ -397,9 +398,9 @@ export function make(options: ClientOptions) {
|
||||
|
||||
return {
|
||||
server: {
|
||||
status: (requestOptions?: RequestOptions) =>
|
||||
request<ServerStatusOutput>(
|
||||
{ method: "GET", path: `/api/status`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
info: (requestOptions?: RequestOptions) =>
|
||||
request<ServerInfoOutput>(
|
||||
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
@@ -416,6 +417,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reload: (requestOptions?: RequestOptions) =>
|
||||
request<LocationReloadOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/location/reload`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 503],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
agent: {
|
||||
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
|
||||
|
||||
export type ServerStatus = { version: string; pid: number; urls: Array<string> }
|
||||
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
|
||||
|
||||
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
|
||||
|
||||
@@ -820,6 +820,15 @@ export type SessionUsageRecorded = {
|
||||
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
|
||||
}
|
||||
|
||||
export type LocationShutdown = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "location.shutdown"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2321,6 +2330,7 @@ export type IntegrationInfo = {
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
| LocationShutdown
|
||||
| ModelsDevRefreshed
|
||||
| CredentialUpdated
|
||||
| CredentialSwitched
|
||||
@@ -2429,14 +2439,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
|
||||
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
@@ -2445,6 +2447,14 @@ export type ServiceUnavailableError = {
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2645,7 +2655,7 @@ export type WorktreeError = {
|
||||
export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
|
||||
|
||||
export type ServerStatusOutput = ServerStatus
|
||||
export type ServerInfoOutput = ServerInfo
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
@@ -2653,6 +2663,8 @@ export type LocationGetInput = {
|
||||
|
||||
export type LocationGetOutput = LocationPublicInfo
|
||||
|
||||
export type LocationReloadOutput = void
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
@@ -174,7 +174,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
|
||||
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
|
||||
// to recognize the registered daemon as incompatible and route it through replacement.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
@@ -187,15 +187,16 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const status = decodeStatus(result.value.body)
|
||||
if (status !== undefined) {
|
||||
if (status.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && status.version !== info.version) return { service: undefined, timedOut: false }
|
||||
const serverInfo = decodeInfo(result.value.body)
|
||||
if (serverInfo !== undefined) {
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: status.version,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
@@ -205,7 +206,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
|
||||
return { service: undefined, timedOut: false }
|
||||
}
|
||||
|
||||
function decodeStatus(input: unknown) {
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
|
||||
@@ -15,21 +15,31 @@ import {
|
||||
|
||||
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
||||
|
||||
test("server.status decodes the readiness response", async () => {
|
||||
test("server.info decodes the readiness response", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({ version: "current", pid: 123, urls: ["http://localhost:3000"] }),
|
||||
Response.json({
|
||||
version: "current",
|
||||
pid: 123,
|
||||
urls: ["http://localhost:3000"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.server.status()
|
||||
return yield* client.server.info()
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result).toEqual({ version: "current", pid: 123, urls: ["http://localhost:3000"] })
|
||||
expect(result).toEqual({
|
||||
version: "current",
|
||||
pid: 123,
|
||||
urls: ["http://localhost:3000"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
})
|
||||
|
||||
test("vcs.base decodes nullable review-base metadata", async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ const server = Bun.serve({
|
||||
await writeFile(registration + ".prepared", JSON.stringify(handoff))
|
||||
return Response.json({ handoff })
|
||||
}
|
||||
if (pathname !== "/api/status") return new Response(null, { status: 404 })
|
||||
if (pathname !== "/api/info") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".status-request", "")
|
||||
if (mode === "hanging") {
|
||||
@@ -65,10 +65,21 @@ const server = Bun.serve({
|
||||
return new Response(null, { status: 503 })
|
||||
}
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 503 })
|
||||
return Response.json(
|
||||
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
|
||||
{ status: 503 },
|
||||
)
|
||||
if (mode === "failed-owner")
|
||||
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 500 })
|
||||
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] })
|
||||
return Response.json(
|
||||
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
|
||||
{ status: 500 },
|
||||
)
|
||||
return Response.json({
|
||||
version,
|
||||
pid: process.pid,
|
||||
urls: [server.url.toString()],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -192,19 +192,29 @@ test("websearch.query uses the public HTTP contract", async () => {
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("server.status uses the public HTTP contract", async () => {
|
||||
test("server.info uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
|
||||
return Response.json({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://192.168.1.10:4096"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.server.status()).toEqual({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
|
||||
expect(await client.server.info()).toEqual({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://192.168.1.10:4096"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/status")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/info")
|
||||
})
|
||||
|
||||
test("experimental wellknown integration add uses the public HTTP contract", async () => {
|
||||
@@ -686,7 +696,7 @@ test("event.subscribe reports heartbeat comments as stream activity", async () =
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event transport passes through ordinary status requests", async () => {
|
||||
test("event transport passes through ordinary info requests", async () => {
|
||||
const requests: string[] = []
|
||||
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
|
||||
const client = OpenCode.make({
|
||||
@@ -699,16 +709,22 @@ test("event transport passes through ordinary status requests", async () => {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://localhost:3000"] })
|
||||
return Response.json({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://localhost:3000"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
},
|
||||
})
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
await expect(client.server.status()).resolves.toEqual({
|
||||
await expect(client.server.info()).resolves.toEqual({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://localhost:3000"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
expect(requests).toEqual(["/api/event", "/api/status"])
|
||||
expect(requests).toEqual(["/api/event", "/api/info"])
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
|
||||
@@ -300,5 +300,5 @@ function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
|
||||
}
|
||||
|
||||
async function status(url: string) {
|
||||
return fetch(new URL("/api/status", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
return fetch(new URL("/api/info", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
|
||||
@@ -182,7 +182,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors.
|
||||
non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -472,6 +473,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
|
||||
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
|
||||
plain data).
|
||||
- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's
|
||||
`code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its
|
||||
own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is
|
||||
left behind rather than replacing the error.
|
||||
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
|
||||
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
|
||||
step if `new X()` in a program is ever needed.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
|
||||
import { containsRuntimeReference } from "./references.js"
|
||||
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
|
||||
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
|
||||
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
|
||||
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { formatValue } from "../stdlib/console.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
@@ -146,9 +146,17 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const prototype = builtins[type]
|
||||
const construct = (args: Array<unknown>, newTarget: Callable) => {
|
||||
const proto = prototypeFrom(newTarget, prototype)
|
||||
return type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
const created =
|
||||
type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
|
||||
const options = args[type === "AggregateError" ? 2 : 1]
|
||||
if (!(options instanceof Obj) || !has(options, "cause")) return created
|
||||
return Effect.map(created, (value) => {
|
||||
define(value, "cause", get(options, "cause"), hidden)
|
||||
return value
|
||||
})
|
||||
}
|
||||
const ctor: Native<R> = constructor<R>(builtins, prototype, {
|
||||
name: type,
|
||||
|
||||
@@ -5,13 +5,16 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
import { Throw, typeError } from "./model.js"
|
||||
import { PendingThrow, Throw, typeError } from "./model.js"
|
||||
import { fn } from "./native.js"
|
||||
import {
|
||||
Callable,
|
||||
define,
|
||||
entries,
|
||||
get,
|
||||
has,
|
||||
hidden,
|
||||
keys,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
@@ -60,14 +63,28 @@ export const extensionGlobals = <R>(
|
||||
) {
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
if (value instanceof ErrorObj) {
|
||||
const name = coerceToString(get(value, "name"))
|
||||
const message = get(value, "message")
|
||||
const text = message === undefined ? "" : coerceToString(message)
|
||||
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
|
||||
const copied =
|
||||
name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
|
||||
for (const key of new Set(["cause", ...keys(value)])) {
|
||||
if (uncrossed.has(key) || !has(value, key)) continue
|
||||
const item = crossing(() => next(get(value, key)))
|
||||
if (item === left) continue
|
||||
Object.defineProperty(copied, key, {
|
||||
value: item,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: key !== "cause",
|
||||
})
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
const copied =
|
||||
value instanceof Arr
|
||||
? value.items.map(next)
|
||||
@@ -85,18 +102,29 @@ export const extensionGlobals = <R>(
|
||||
if (isPrimitive(value)) return value
|
||||
if (typeof value === "function") return wrap(value, label)
|
||||
if (value !== null && typeof value === "object") {
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
|
||||
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
|
||||
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
|
||||
if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)))
|
||||
if (value instanceof Error) {
|
||||
return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
|
||||
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
|
||||
seen.add(value)
|
||||
const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
|
||||
const fields = value as unknown as Record<string, unknown>
|
||||
for (const key of new Set(["cause", ...Object.keys(value)])) {
|
||||
if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue
|
||||
const item = crossing(() => next(fields[key], `${label}.${key}`))
|
||||
if (item === left) continue
|
||||
define(copied, key, item, key === "cause" ? hidden : undefined)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
@@ -160,6 +188,22 @@ export const extensionGlobals = <R>(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
|
||||
* `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
|
||||
* cross (a socket, a handle, a function) is left behind so the error itself always arrives.
|
||||
*/
|
||||
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
|
||||
const left = Symbol("left behind")
|
||||
const crossing = (convert: () => unknown): unknown => {
|
||||
try {
|
||||
return convert()
|
||||
} catch (reason) {
|
||||
if (reason instanceof PendingThrow) return left
|
||||
throw reason
|
||||
}
|
||||
}
|
||||
|
||||
const hostErrors = new Map<string, ErrorConstructor>([
|
||||
["TypeError", TypeError],
|
||||
["RangeError", RangeError],
|
||||
|
||||
@@ -163,12 +163,26 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(held[0]).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("a program Error crosses as a host Error with its name and message", async () => {
|
||||
test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => {
|
||||
held.length = 0
|
||||
await value(`keep(new TypeError("bad"))`)
|
||||
expect(held[0]).toBeInstanceOf(TypeError)
|
||||
expect((held[0] as Error).message).toBe("bad")
|
||||
expect(Object.keys(held[0] as object)).toEqual([])
|
||||
held.length = 0
|
||||
await value(`
|
||||
const e = new Error("m", { cause: new RangeError("root") })
|
||||
e.code = "ENOENT"; e.detail = { path: "x" }
|
||||
e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1
|
||||
keep(e)`)
|
||||
const crossed = held[0] as Error & Record<string, unknown>
|
||||
expect(crossed.cause).toBeInstanceOf(RangeError)
|
||||
expect((crossed.cause as Error).message).toBe("root")
|
||||
expect(Object.keys(crossed)).toEqual(["code", "detail"])
|
||||
expect(crossed.detail).toEqual({ path: "x" })
|
||||
expect(crossed.stack).not.toBe("chosen")
|
||||
expect(String(crossed)).toBe("Error: m")
|
||||
expect(crossed.constructor).toBe(Error)
|
||||
})
|
||||
|
||||
test("an Error with an unknown name crosses as a plain Error", async () => {
|
||||
@@ -231,6 +245,49 @@ describe("host errors", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => {
|
||||
class Handle {}
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "fs",
|
||||
globals: {
|
||||
open: () => {
|
||||
const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: "x",
|
||||
detail: { retried: true },
|
||||
handle: new Handle(),
|
||||
retry: () => 1,
|
||||
})
|
||||
throw new Error("open failed", { cause: error })
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(
|
||||
await value(
|
||||
`try { open() } catch (e) {
|
||||
const c = e.cause
|
||||
return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c]
|
||||
}`,
|
||||
target,
|
||||
),
|
||||
).toEqual([
|
||||
"open failed",
|
||||
[],
|
||||
true,
|
||||
"ENOENT",
|
||||
-2,
|
||||
"x",
|
||||
{ retried: true },
|
||||
["code", "errno", "path", "detail"],
|
||||
false,
|
||||
])
|
||||
})
|
||||
|
||||
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
|
||||
const reason = { status: 404, nested: { a: 1 } }
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -235,7 +235,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
path: "/api/fs/read/*",
|
||||
reason: "binary responses are not supported",
|
||||
})
|
||||
expect(toolAt(result.tools, "server.status")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
|
||||
|
||||
@@ -978,10 +978,10 @@ describe("OpenAPI.fromSpec", () => {
|
||||
|
||||
expect(spec.security).toStrictEqual([])
|
||||
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
|
||||
const status = toolAt(result.tools, "server.status")
|
||||
const statusInput = Tool.isTool(status) && isRecord(status.input) ? status.input : undefined
|
||||
expect(statusInput).toMatchObject({ type: "object", properties: {} })
|
||||
const input = isRecord(statusInput) ? statusInput : {}
|
||||
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([])
|
||||
})
|
||||
|
||||
@@ -994,7 +994,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
return search({ query: "server status", namespace: "opencode", limit: 1 })
|
||||
return search({ query: "server info", namespace: "opencode", limit: 1 })
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.provide(layer)),
|
||||
@@ -1005,8 +1005,8 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result.value).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
path: "tools.opencode.server.status",
|
||||
description: "Return the server identity, connection URLs, and readiness status.",
|
||||
path: "tools.opencode.server.info",
|
||||
description: "Return the server identity, connection URLs, paths, and readiness status.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -350,6 +350,16 @@ describe("Error values and instanceof", () => {
|
||||
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
|
||||
})
|
||||
|
||||
test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const inner = new Error("root")
|
||||
const e = new TypeError("m", { cause: inner })
|
||||
const agg = new AggregateError([], "a", { cause: 3 })
|
||||
return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`),
|
||||
).toEqual([true, [], false, true, 3])
|
||||
})
|
||||
|
||||
test("thrown errors keep instanceof through try/catch", async () => {
|
||||
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
|
||||
true,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ListInput {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly close: Effect.Effect<void>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
|
||||
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
|
||||
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
||||
@@ -100,6 +101,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
let closed = false
|
||||
const forms = yield* Cache.makeWith<ID, Entry>(
|
||||
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
|
||||
{
|
||||
@@ -137,6 +139,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
yield* Cache.set(forms, id, entry)
|
||||
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
|
||||
if (closed) yield* cancel(id).pipe(Effect.orDie)
|
||||
return form
|
||||
}),
|
||||
),
|
||||
@@ -202,19 +205,21 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Cache.values(forms).pipe(
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(
|
||||
Array.from(entries).filter((entry) => entry.state.status === "pending"),
|
||||
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
const close = Effect.sync(() => {
|
||||
closed = true
|
||||
}).pipe(
|
||||
Effect.andThen(Cache.values(forms)),
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(
|
||||
Array.from(entries).filter((entry) => entry.state.status === "pending"),
|
||||
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => close)
|
||||
|
||||
return Service.of({ create, ask, get, list, state, reply, cancel })
|
||||
return Service.of({ create, ask, get, list, state, reply, cancel, close })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Model } from "./model.js"
|
||||
@@ -18,6 +18,7 @@ import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationLifecycle } from "./location-lifecycle.js"
|
||||
import { FileAccess } from "./file-access.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
@@ -57,6 +58,7 @@ export { Service, node, type Interface } from "./instance/service.js"
|
||||
|
||||
const nodes = [
|
||||
Location.node,
|
||||
LocationLifecycle.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
@@ -157,6 +159,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
|
||||
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
|
||||
// Instance boot failures are defects; provided operations retain their typed errors.
|
||||
Layer.orDie,
|
||||
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export * as LocationLifecycle from "./location-lifecycle.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { LocationEvent } from "@opencode/schema/location-event"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
|
||||
>()("@opencode/LocationLifecycle") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
let closed = false
|
||||
const shutdown = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
closed = true
|
||||
yield* permission.close
|
||||
yield* forms.close
|
||||
yield* rpc.close
|
||||
yield* bus.publish(
|
||||
LocationEvent.Shutdown,
|
||||
{},
|
||||
{
|
||||
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.uninterruptible),
|
||||
)
|
||||
return Service.of({
|
||||
isClosed: () => closed,
|
||||
shutdown,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { Context, Effect, Exit, Layer, LayerMap, RcMap } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Node } from "@opencode/util/effect/app-node"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
@@ -17,6 +17,24 @@ export class Service extends Context.Service<
|
||||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
export const reload = Effect.fn("LocationServiceMap.reload")(function* () {
|
||||
const locations = yield* Service
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
yield* Effect.forEach(refs, (ref) => locations.invalidate(ref), {
|
||||
discard: true,
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
// Boot every replacement now and let all builds settle even if one fails.
|
||||
const results = yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => Effect.scoped(locations.contextEffect(ref)).pipe(Effect.asVoid, Effect.exit),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const failure = results.find(Exit.isFailure)
|
||||
if (failure) return yield* Effect.failCause(failure.cause)
|
||||
yield* Effect.logInfo("location services reloaded", { count: refs.length })
|
||||
})
|
||||
|
||||
/** Normalize equivalent placements before they become resource-cache keys. */
|
||||
export function canonical(ref: Location.Ref) {
|
||||
return Location.Ref.make({
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Optio
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationLifecycle } from "./location-lifecycle.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
@@ -28,12 +28,17 @@ export function buildLocationServiceMap(
|
||||
).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const finish = Effect.suspend(() => {
|
||||
if (Exit.isSuccess(exit)) {
|
||||
return Effect.gen(function* () {
|
||||
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
|
||||
// A boot detached while in flight still needs shutdown and cancellation.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
|
||||
return yield* lifecycle.shutdown
|
||||
build.close = lifecycle.shutdown
|
||||
})
|
||||
}
|
||||
// An explicitly invalidated build must not evict its replacement.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void
|
||||
if (Exit.isSuccess(exit)) {
|
||||
build.close = Context.get(exit.value, Rpc.Service).close
|
||||
return Effect.void
|
||||
}
|
||||
MutableHashMap.remove(builds, ref)
|
||||
// Evict once per failed build, before its result reaches borrowers.
|
||||
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
|
||||
@@ -61,10 +66,11 @@ export function buildLocationServiceMap(
|
||||
const key = LocationServiceMap.canonical(ref)
|
||||
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
|
||||
MutableHashMap.remove(builds, key)
|
||||
// Detach routing first, then end pending RPCs that still borrow the old graph.
|
||||
// Detach routing first, then cancel interactions and notify clients. Running
|
||||
// steps retain their borrowed graph until they can hand off at a boundary.
|
||||
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
|
||||
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
|
||||
}),
|
||||
}).pipe(Effect.uninterruptible),
|
||||
}
|
||||
// Cached instances borrow their owner instead of retaining its Layer scope.
|
||||
const bindings: LayerNode.Replacements = [
|
||||
|
||||
@@ -81,6 +81,8 @@ type ServerEntry = {
|
||||
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
|
||||
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
||||
const URL_ELICITATION_FIELD_KEY = "elicitation"
|
||||
// Connections remain Location-scoped, but shared remote endpoints should not receive concurrent startup bursts.
|
||||
const endpointLoads = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
type Data = {
|
||||
servers: Map<ServerName, Types.DeepMutable<Mcp.ServerConfig>>
|
||||
@@ -387,7 +389,7 @@ export const layer = (options?: Options) =>
|
||||
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* McpClient.connect(
|
||||
const load = McpClient.connect(
|
||||
name,
|
||||
entry.config,
|
||||
location.directory,
|
||||
@@ -399,8 +401,10 @@ export const layer = (options?: Options) =>
|
||||
// A stdio server is spawned on this location's execution plane, not the host's.
|
||||
Effect.provideService(Environment.Service, environment),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
)
|
||||
const result = yield* (
|
||||
entry.config.type === "remote" ? endpointLoads.withLock(entry.config.url)(load) : load
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(result)) {
|
||||
entry.client = result.value.connection
|
||||
entry.tools = result.value.tools.map((tool) => toTool(name, entry, tool))
|
||||
|
||||
@@ -101,6 +101,7 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly close: Effect.Effect<void>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -127,18 +128,22 @@ const layer = Layer.effect(
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
let closed = false
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const close = Effect.gen(function* () {
|
||||
closed = true
|
||||
yield* Effect.forEach(Array.from(pending.values()), (item) =>
|
||||
bus
|
||||
.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
.pipe(Effect.ensuring(Deferred.fail(item.deferred, new DeclinedError()))),
|
||||
)
|
||||
pending.clear()
|
||||
}).pipe(Effect.uninterruptible)
|
||||
yield* Effect.addFinalizer(() => close)
|
||||
|
||||
const savedRules = Effect.fnUntraced(function* () {
|
||||
return (yield* saved.list({ projectID: location.project.id })).map(
|
||||
@@ -201,6 +206,10 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
|
||||
const item = { request, agent, deferred }
|
||||
if (closed) {
|
||||
yield* Deferred.fail(deferred, new DeclinedError())
|
||||
return item
|
||||
}
|
||||
if (pending.has(request.id))
|
||||
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
|
||||
pending.set(request.id, item)
|
||||
@@ -212,6 +221,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
|
||||
if (closed) return { id: input.id ?? ID.create(), effect: "deny" as const }
|
||||
const result = yield* evaluateInput(input)
|
||||
const value = request(input, result.message)
|
||||
if (result.effect === "ask") yield* create(value, input.agent)
|
||||
@@ -220,6 +230,7 @@ const layer = Layer.effect(
|
||||
|
||||
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return yield* Effect.die(new DeclinedError())
|
||||
const result = yield* evaluateInput(input)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -321,7 +332,7 @@ const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ ask, assert, reply, get, forSession, list })
|
||||
return Service.of({ ask, assert, reply, get, forSession, list, close })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -42,13 +42,17 @@ that section.
|
||||
CLI and TUI preferences are separate from OpenCode's server and project
|
||||
configuration. They live in the global `~/.config/opencode/cli.json`, or
|
||||
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
|
||||
project-local CLI configuration. Most preferences can also be changed from the
|
||||
TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
project-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge
|
||||
inline JSON over the global settings. Most preferences can also be changed from
|
||||
the TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
|
||||
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
|
||||
before editing `cli.json`. It covers terminal-only settings such as themes,
|
||||
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
|
||||
and terminal integration. Do not put these settings in `opencode.json(c)`.
|
||||
### [Settings](https://opencode.ai/v2/docs/cli/config)
|
||||
|
||||
Fetch the full [CLI settings reference](https://opencode.ai/v2/docs/cli/config)
|
||||
before editing `cli.json`. It documents every terminal-only setting, accepted
|
||||
values, and examples, including themes, input, sessions, tabs, diffs, alerts,
|
||||
Mini, keybindings, terminal plugins, and debugging. Do not put these settings
|
||||
in `opencode.json(c)`.
|
||||
|
||||
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
|
||||
|
||||
@@ -92,7 +96,7 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`references`, `formatter`, and `lsp`.
|
||||
|
||||
This configuration is distinct from `cli.json`. Use the
|
||||
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
|
||||
[CLI settings reference](https://opencode.ai/v2/docs/cli/config) for terminal
|
||||
preferences, especially themes and keybindings.
|
||||
|
||||
Do not guess field names or shapes. Fetch the V2 configuration guide and its
|
||||
@@ -190,7 +194,7 @@ HTTP method and path or an OpenAPI operation ID.
|
||||
Call an endpoint with an HTTP method and path:
|
||||
|
||||
```sh
|
||||
opencode api get /api/status
|
||||
opencode api get /api/info
|
||||
```
|
||||
|
||||
Pass a request body with `--data` or `-d`, and additional headers with
|
||||
@@ -241,7 +245,7 @@ OpenCode runs a client and a background server. Start by determining whether a
|
||||
problem belongs to the client, the shared server, or one project.
|
||||
|
||||
- Check the service with `opencode service status` and verify the API with
|
||||
`opencode api get /api/status`.
|
||||
`opencode api get /api/info`.
|
||||
- Compare with `opencode --standalone`, which runs the TUI with a private
|
||||
server, to isolate shared-service issues.
|
||||
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
|
||||
|
||||
@@ -57,6 +57,9 @@ export const Plugin = define({
|
||||
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie)
|
||||
if (session.parentID) return
|
||||
|
||||
const active = sessions.get(event.sessionID)
|
||||
const settings = yield* loadSettings()
|
||||
if (!settings) {
|
||||
|
||||
@@ -108,6 +108,7 @@ export const layer = Layer.effect(
|
||||
return yield* SessionRunner.DrainResult.$match(result, {
|
||||
Complete: () => Effect.void,
|
||||
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
|
||||
Reloaded: (result) => drain(sessionID, result.force, result.continuation, promotable),
|
||||
})
|
||||
})
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
|
||||
@@ -100,7 +100,6 @@ export const layer = (options?: Options) =>
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
suspended: ReadonlySet<SessionSchema.ID>,
|
||||
) {
|
||||
const state = background.status === "running" ? "cancelled" : background.status
|
||||
const text =
|
||||
@@ -124,7 +123,9 @@ export const layer = (options?: Options) =>
|
||||
state,
|
||||
text,
|
||||
}),
|
||||
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
|
||||
// Restart notices must not revive idle owners of long-lived shells.
|
||||
// Interrupted executions resume separately after their notices are admitted.
|
||||
resume: false,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
@@ -208,7 +209,7 @@ export const layer = (options?: Options) =>
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery, suspended)
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
}),
|
||||
{ discard: true },
|
||||
|
||||
@@ -320,15 +320,24 @@ export const layer = Layer.effect(
|
||||
// which transport actually carries the request, so both hook families are always offered.
|
||||
const webSocket =
|
||||
input.webSocket === "session" && model.transport === "websocket"
|
||||
? transport.bind(session.id, (connect) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.handshake", {
|
||||
...scope,
|
||||
url: connect.url,
|
||||
headers: connect.headers,
|
||||
})
|
||||
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
|
||||
)
|
||||
? transport.bind(session.id, {
|
||||
handshake: (connect) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.handshake", {
|
||||
...scope,
|
||||
url: connect.url,
|
||||
headers: connect.headers,
|
||||
})
|
||||
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
|
||||
send: (frame) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.send", { ...scope, frame })
|
||||
.pipe(Effect.map((event) => event.frame)),
|
||||
receive: (frame) =>
|
||||
hooks
|
||||
.trigger("session", "experimental.ws.receive", { ...scope, frame })
|
||||
.pipe(Effect.map((event) => event.frame)),
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
|
||||
@@ -59,11 +59,18 @@ export interface Handshake {
|
||||
readonly headers: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-exchange taps. `handshake` runs before the connection is selected; `send` sees each outbound
|
||||
* frame after the driver builds it; `receive` sees each inbound frame before the driver observes it.
|
||||
*/
|
||||
export interface Interceptor {
|
||||
readonly handshake?: (connect: Handshake) => Effect.Effect<Handshake>
|
||||
readonly send?: (frame: string) => Effect.Effect<string>
|
||||
readonly receive?: (frame: string) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly bind: (
|
||||
sessionID: SessionSchema.ID,
|
||||
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
|
||||
) => WebSocketChannelExecutor
|
||||
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
|
||||
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
@@ -278,7 +285,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
input: WebSocketChannelExchange,
|
||||
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
|
||||
interceptor?: Interceptor,
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -288,8 +295,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(input)
|
||||
const selected = handshake
|
||||
? yield* handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
|
||||
const selected = interceptor?.handshake
|
||||
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
|
||||
: undefined
|
||||
const exchange: WebSocketChannelExchange = selected
|
||||
? { ...input, connect: { ...input.connect, url: selected.url, headers: Headers.fromInput(selected.headers) } }
|
||||
@@ -354,6 +361,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const message = interceptor?.send
|
||||
? yield* interceptor.send(create.message).pipe(Effect.onInterrupt(() => closeChannel(owner, channel)))
|
||||
: create.message
|
||||
yield* Effect.logDebug("session websocket sending", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "send",
|
||||
@@ -364,7 +374,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
const sent = yield* channel.connection.sendText(message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
Effect.result,
|
||||
@@ -405,6 +415,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Stream.mapEffect((frame) => (interceptor?.receive ? interceptor.receive(frame) : Effect.succeed(frame))),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
@@ -482,10 +493,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
return { frames, complete, http: channel.connection.http }
|
||||
})
|
||||
|
||||
const bind = (
|
||||
sessionID: SessionSchema.ID,
|
||||
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
|
||||
): WebSocketChannelExecutor => ({
|
||||
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
@@ -495,7 +503,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, handshake)),
|
||||
Effect.andThen(start(owner, exchange, interceptor)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -22,6 +22,7 @@ export type Continuation = { readonly step: number }
|
||||
export type DrainResult = Data.TaggedEnum<{
|
||||
Complete: {}
|
||||
Moved: { readonly continuation?: Continuation }
|
||||
Reloaded: { readonly force: boolean; readonly continuation?: Continuation }
|
||||
}>
|
||||
export const DrainResult = Data.taggedEnum<DrainResult>()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { LocationLifecycle } from "../../location-lifecycle.js"
|
||||
import { InstructionState } from "../instruction-state.js"
|
||||
import { SessionCompaction } from "../compaction.js"
|
||||
import { SessionContext } from "../context.js"
|
||||
@@ -37,6 +38,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const lifecycle = yield* LocationLifecycle.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const modelTransport = yield* SessionModelTransport.Service
|
||||
@@ -69,6 +71,10 @@ const layer = Layer.effect(
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
if (lifecycle.isClosed()) {
|
||||
yield* restore(modelTransport.close(sessionID))
|
||||
return DrainResult.Reloaded({ force, continuation: continuing ? { step } : undefined })
|
||||
}
|
||||
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
@@ -240,6 +246,7 @@ const layer = Layer.effect(
|
||||
webSocket: "session",
|
||||
})
|
||||
const outcome = yield* steps.attempt({
|
||||
isLocationClosed: lifecycle.isClosed,
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
@@ -356,6 +363,7 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
Bus.node,
|
||||
LocationLifecycle.node,
|
||||
llmClient,
|
||||
SessionContext.node,
|
||||
SessionModelTransport.node,
|
||||
|
||||
@@ -74,7 +74,13 @@ const retryAfter = (input: Input) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
// Exponential from 2s capped at 10s per gap, for 10 retries: 2, 4, 8, then 10 × 7, about 84s of
|
||||
// waiting when every attempt fails (67–101s with jitter). `min` takes the faster schedule, so the
|
||||
// cap applies per gap; `max` with `recurs` bounds the count.
|
||||
const schedule = Schedule.max([
|
||||
Schedule.min([Schedule.exponential("2 seconds"), Schedule.spaced("10 seconds")]),
|
||||
Schedule.recurs(10),
|
||||
]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<Input>(),
|
||||
Schedule.modifyDelay(({ input, duration: delay }) => {
|
||||
|
||||
@@ -42,6 +42,7 @@ export type Outcome = Data.TaggedEnum<{
|
||||
export const Outcome = Data.taggedEnum<Outcome>()
|
||||
|
||||
interface Input {
|
||||
readonly isLocationClosed: () => boolean
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -190,8 +191,9 @@ export const make = Effect.gen(function* () {
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
message: input.isLocationClosed()
|
||||
? "Interaction cancelled because the location shut down"
|
||||
: decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
@@ -251,7 +253,10 @@ export const make = Effect.gen(function* () {
|
||||
return Outcome.Continue({ error: llmError, decision: retry })
|
||||
|
||||
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.declines.length > 0) {
|
||||
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
|
||||
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
||||
it.effect("classifies data-only AI SDK provider codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const data = {
|
||||
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
|
||||
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
|
||||
trace: { region: "test-region" },
|
||||
}
|
||||
const cause = apiCallError({ statusCode: 400, data })
|
||||
const error = yield* streamFailure(cause)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason.http?.status).toBe(400)
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect(error.reason.body).toBe(JSON.stringify(data))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
|
||||
Layer.mock(Permission.Service, overrides)
|
||||
Layer.mock(Permission.Service, { close: Effect.void, ...overrides })
|
||||
|
||||
@@ -55,6 +55,7 @@ const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text
|
||||
const permissionLayer = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
close: Effect.void,
|
||||
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
|
||||
assert: () => Effect.void,
|
||||
reply: () => Effect.void,
|
||||
|
||||
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
})
|
||||
|
||||
describe("SessionRestart background recovery", () => {
|
||||
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
|
||||
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
@@ -416,6 +416,20 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
|
||||
|
||||
expect(drained).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
|
||||
|
||||
yield* seedInbox(database, parent, ["steer"])
|
||||
yield* seedInbox(database, child, ["steer"])
|
||||
yield* execution.wake(parent)
|
||||
yield* execution.wake(child)
|
||||
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([parent, child].toSorted())
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toMatchObject([
|
||||
{
|
||||
@@ -509,7 +523,7 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
|
||||
|
||||
expect(drained).toEqual([sessionID])
|
||||
expect(drained).toEqual([])
|
||||
const inbox = yield* SessionInbox.list(database.db, sessionID)
|
||||
expect(inbox).toMatchObject([
|
||||
{
|
||||
@@ -561,7 +575,6 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
{
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the handshake hook", () =>
|
||||
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the WebSocket hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: string[] = []
|
||||
@@ -92,13 +92,31 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
delete event.headers["api-key"]
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.send", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`send:${event.kind}:${event.frame}`)
|
||||
event.frame = `${event.frame}+plugin`
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.receive", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`receive:${event.kind}:${event.frame}`)
|
||||
event.frame = event.frame.toUpperCase()
|
||||
}),
|
||||
)
|
||||
const bound: Array<{ url: string; headers: Record<string, string> }> = []
|
||||
const frames: string[] = []
|
||||
const websocketTransport = SessionModelTransport.Service.of({
|
||||
bind: (_sessionID, handshake) => ({
|
||||
bind: (_sessionID, interceptor) => ({
|
||||
execute: () =>
|
||||
Effect.gen(function* () {
|
||||
if (!handshake) throw new Error("Expected a handshake interceptor")
|
||||
bound.push(yield* handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }))
|
||||
if (!interceptor?.handshake || !interceptor.send || !interceptor.receive)
|
||||
throw new Error("Expected a full WebSocket interceptor")
|
||||
bound.push(
|
||||
yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }),
|
||||
)
|
||||
frames.push(yield* interceptor.send("create"))
|
||||
frames.push(yield* interceptor.receive("created"))
|
||||
return { frames: Stream.empty, complete: Effect.void }
|
||||
}),
|
||||
}),
|
||||
@@ -127,7 +145,12 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
expect(prepared.options.webSocket).toBeDefined()
|
||||
yield* prepared.options.webSocket!.execute({} as never)
|
||||
expect(bound).toEqual([{ url: "wss://example.test/v1/responses", headers: { authorization: "Bearer minted" } }])
|
||||
expect(seen).toEqual(["handshake:primary:wss://example.test/v1/responses"])
|
||||
expect(frames).toEqual(["create+plugin", "CREATED"])
|
||||
expect(seen).toEqual([
|
||||
"handshake:primary:wss://example.test/v1/responses",
|
||||
"send:primary:create",
|
||||
"receive:primary:created",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -178,12 +178,13 @@ describe("SessionModelTransport", () => {
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session, (connect) =>
|
||||
Effect.succeed({
|
||||
url: connect.url,
|
||||
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
|
||||
}),
|
||||
)
|
||||
const executor = transport.bind(session, {
|
||||
handshake: (connect) =>
|
||||
Effect.succeed({
|
||||
url: connect.url,
|
||||
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
|
||||
}),
|
||||
})
|
||||
yield* collect(executor, exchange("first", { headers: { "api-key": "k" } }))
|
||||
yield* collect(executor, exchange("second", { headers: { "api-key": "k" } }))
|
||||
yield* collect(executor, exchange("third", { headers: { "api-key": "k" } }))
|
||||
@@ -196,6 +197,36 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("sends the frame the send tap returns and observes the frame the receive tap returns", async () => {
|
||||
const fixture = automatic()
|
||||
const seen: Array<{ tap: "send" | "receive"; frame: string }> = []
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session, {
|
||||
send: (frame) => {
|
||||
seen.push({ tap: "send", frame })
|
||||
return Effect.succeed(`${frame}:rewritten`)
|
||||
},
|
||||
receive: (frame) => {
|
||||
seen.push({ tap: "receive", frame })
|
||||
return Effect.succeed(`${frame}:observed`)
|
||||
},
|
||||
})
|
||||
const frames = yield* collect(executor, exchange("first"))
|
||||
|
||||
// The wire carries the rewritten outbound frame; the driver sees the rewritten inbound frame.
|
||||
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:rewritten"]])
|
||||
expect(frames).toEqual(["completed:first:rewritten:observed"])
|
||||
expect(seen).toEqual([
|
||||
{ tap: "send", frame: "first" },
|
||||
{ tap: "receive", frame: "completed:first:rewritten" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not carry a checkpoint across physical connection rotation", async () => {
|
||||
const fixture = automatic()
|
||||
const checkpoints: Array<unknown> = []
|
||||
|
||||
@@ -580,6 +580,12 @@ const scenario = (
|
||||
}),
|
||||
)
|
||||
|
||||
// Nominal retry gaps: exponential from 2s capped at 10s, for 10 retries.
|
||||
const RETRY_GAPS = [2_000, 4_000, 8_000, ...Array<number>(7).fill(10_000)]
|
||||
// Longest possible gap per retry (+20% jitter); advancing the clock by these always fires the retry.
|
||||
const RETRY_GAPS_MAX = RETRY_GAPS.map((gap) => gap * 1.2)
|
||||
const RETRY_ATTEMPTS = RETRY_GAPS.map((_, index) => index + 2)
|
||||
|
||||
// Subscribe before resuming; model requests can arrive before retry backoff is scheduled.
|
||||
const subscribeRetries = (s: Scenario) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -2676,8 +2682,8 @@ describe("SessionRunnerLLM", () => {
|
||||
const compaction = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(attempts).toEqual([2, 3, 4, 5])
|
||||
expect(s.requests).toHaveLength(6)
|
||||
expect(attempts).toEqual(RETRY_ATTEMPTS)
|
||||
expect(s.requests).toHaveLength(RETRY_ATTEMPTS.length + 2)
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
@@ -5476,28 +5482,34 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textStart({ id: "mixed-partial" }),
|
||||
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
|
||||
)
|
||||
yield* s.llm.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
|
||||
// Alternate transparent failures and partial continuations until the retry allowance is spent.
|
||||
const outcomes = RETRY_GAPS.map((_, index) => (index % 2 === 0 ? Stream.fail(failure) : partial))
|
||||
yield* s.llm.push(...outcomes, partial)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
const identities: SessionMessage.ID[] = []
|
||||
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
|
||||
for (const delay of RETRY_GAPS_MAX) {
|
||||
identities.push(yield* Queue.take(scheduled))
|
||||
yield* TestClock.adjust(delay)
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(s.requests).toHaveLength(5)
|
||||
expect(identities[0]).toBe(identities[1])
|
||||
expect(identities[2]).toBe(identities[3])
|
||||
expect(identities[0]).not.toBe(identities[2])
|
||||
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
|
||||
// A transparent retry keeps the assistant identity; a partial continuation starts a new one.
|
||||
for (const [index, identity] of identities.entries()) {
|
||||
if (index === 0) continue
|
||||
if (index % 2 === 1) expect(identity).toBe(identities[index - 1])
|
||||
else expect(identity).not.toBe(identities[index - 1])
|
||||
}
|
||||
const partials = outcomes.filter((outcome) => outcome === partial).length
|
||||
const messages = yield* s.context
|
||||
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
|
||||
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
|
||||
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(partials + 1)
|
||||
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(partials)
|
||||
const events = yield* recordedEventTypes(sessionID)
|
||||
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
|
||||
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
|
||||
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(RETRY_GAPS.length)
|
||||
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(partials + 1)
|
||||
},
|
||||
)
|
||||
|
||||
scenario("stops incomplete stream continuations after five total attempts", function* (s) {
|
||||
scenario("stops incomplete stream continuations once the retry allowance is spent", function* (s) {
|
||||
yield* s.admit("Exhaust partial continuations")
|
||||
const failure = incompleteStream()
|
||||
yield* s.llm.always(
|
||||
@@ -5511,30 +5523,30 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const scheduled = yield* subscribeRetries(s)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
|
||||
for (const delay of RETRY_GAPS_MAX) {
|
||||
yield* Queue.take(scheduled)
|
||||
yield* TestClock.adjust(delay)
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(s.requests).toHaveLength(5)
|
||||
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
|
||||
const context = yield* s.context
|
||||
expect(context.filter((message) => message.type === "assistant")).toHaveLength(5)
|
||||
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(4)
|
||||
expect(context.filter((message) => message.type === "assistant")).toHaveLength(RETRY_GAPS.length + 1)
|
||||
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(RETRY_GAPS.length)
|
||||
})
|
||||
|
||||
scenario("stops after five total retry attempts", function* (s) {
|
||||
scenario("stops once the retry allowance is spent", function* (s) {
|
||||
yield* s.admit("Exhaust retries")
|
||||
const failure = providerUnavailable()
|
||||
yield* s.llm.always(Stream.fail(failure))
|
||||
|
||||
const scheduled = yield* subscribeRetries(s)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
|
||||
for (const delay of RETRY_GAPS_MAX) {
|
||||
yield* Queue.take(scheduled)
|
||||
yield* TestClock.adjust(delay)
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(s.requests).toHaveLength(5)
|
||||
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
|
||||
|
||||
const retries = yield* s.db
|
||||
.select({ data: EventTable.data })
|
||||
@@ -5543,22 +5555,20 @@ describe("SessionRunnerLLM", () => {
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const [index, range] of [
|
||||
[1_600, 2_400],
|
||||
[4_800, 7_200],
|
||||
[11_200, 16_800],
|
||||
[24_000, 36_000],
|
||||
].entries()) {
|
||||
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
|
||||
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
|
||||
// Each scheduled time falls within the jittered cumulative window for that retry.
|
||||
expect(retries).toHaveLength(RETRY_GAPS.length)
|
||||
let elapsed = 0
|
||||
for (const [index, gap] of RETRY_GAPS.entries()) {
|
||||
elapsed += gap
|
||||
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(elapsed * 0.8)
|
||||
expect(retries[index]?.data.at).toBeLessThanOrEqual(elapsed * 1.2)
|
||||
}
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(
|
||||
RETRY_GAPS.length + 1,
|
||||
)
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
{ type: "session.step.started.1" },
|
||||
{ type: "session.step.started.1" },
|
||||
{ type: "session.step.started.1" },
|
||||
{ type: "session.step.started.1" },
|
||||
...RETRY_GAPS.map(() => ({ type: "session.step.started.1" })),
|
||||
{ type: "session.step.started.1" },
|
||||
{ type: "session.step.failed.1" },
|
||||
])
|
||||
|
||||
@@ -100,6 +100,7 @@ for (const fixture of [
|
||||
)
|
||||
const result = yield* steps
|
||||
.attempt({
|
||||
isLocationClosed: () => false,
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
|
||||
@@ -15,6 +15,31 @@ test.each(["build", "serve"] as const)("configures minification for %s", async (
|
||||
expect(result?.config.renderer?.build?.sourcemap).toBe(true)
|
||||
})
|
||||
|
||||
test("onboarding preview is enabled only by the development test flag", async () => {
|
||||
const previous = process.env.OPENCODE_TEST_ONBOARDING
|
||||
try {
|
||||
process.env.OPENCODE_TEST_ONBOARDING = "1"
|
||||
for (const command of ["build", "serve"] as const) {
|
||||
const result = await loadConfigFromFile(
|
||||
{ command, mode: command === "build" ? "production" : "development" },
|
||||
`${import.meta.dirname}/electron.vite.config.ts`,
|
||||
)
|
||||
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe(
|
||||
JSON.stringify(command === "serve"),
|
||||
)
|
||||
}
|
||||
delete process.env.OPENCODE_TEST_ONBOARDING
|
||||
const result = await loadConfigFromFile(
|
||||
{ command: "serve", mode: "development" },
|
||||
`${import.meta.dirname}/electron.vite.config.ts`,
|
||||
)
|
||||
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe("false")
|
||||
} finally {
|
||||
delete process.env.OPENCODE_TEST_ONBOARDING
|
||||
if (previous !== undefined) process.env.OPENCODE_TEST_ONBOARDING = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("does not package external copies of bundled dependencies", () => {
|
||||
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
|
||||
expect(Object.keys(pkg.dependencies)).not.toContain(name)
|
||||
|
||||
@@ -96,6 +96,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
define: {
|
||||
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
"import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify(
|
||||
command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1",
|
||||
),
|
||||
},
|
||||
plugins: [pickerPlugin(), appPlugin, sentry],
|
||||
publicDir: "../../../app/public",
|
||||
|
||||
@@ -42,7 +42,10 @@ export const configureApplication = Effect.fn("Application.configure")(function*
|
||||
|
||||
const testRoot = yield* createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
|
||||
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
})
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
@@ -99,7 +102,7 @@ const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
|
||||
yield* Effect.forEach(
|
||||
["data", "config", "cache", "state", "desktop", "session"],
|
||||
["data", "config", "cache", "state", "desktop", "session", "documents"],
|
||||
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL("/api/status", url), {
|
||||
const res = await fetch(new URL("/api/info", url), {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
|
||||
@@ -354,7 +354,7 @@ const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authentic
|
||||
const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
|
||||
const client = yield* HttpClient.HttpClient
|
||||
return yield* client
|
||||
.get(`${http.url}/api/status`, {
|
||||
.get(`${http.url}/api/info`, {
|
||||
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -120,6 +120,10 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
|
||||
revealed = true
|
||||
win.show()
|
||||
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
runFork(Effect.logInfo("main window visible", { window: id }))
|
||||
}
|
||||
const ready = () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
|
||||
import { createDesktopPlatform } from "./platform"
|
||||
import { bindDesktopMenu } from "./platform/menu"
|
||||
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
|
||||
import { preloadStoredLocale } from "./startup/locale"
|
||||
@@ -37,53 +37,41 @@ const MigrationStatus = lazy(() => import("./migration-status").then((module) =>
|
||||
|
||||
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
|
||||
const windowState = { id: props.api.getWindowID(), version: props.version }
|
||||
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
|
||||
const initialUrl = getLastActiveUrl(windowState.id)
|
||||
const url = new URL(initialUrl, "http://localhost")
|
||||
const route = currentRoute(url.pathname, url.search)
|
||||
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
|
||||
const [startup, setStartup] = createStore({
|
||||
ready: false,
|
||||
visible: true,
|
||||
themeReady: false,
|
||||
onboardingReady: false,
|
||||
drawingReady: false,
|
||||
route,
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<DesktopWindow
|
||||
api={props.api}
|
||||
updater={props.updater}
|
||||
windowState={windowState}
|
||||
onReady={() => setStartup("ready", true)}
|
||||
onRoute={(route) => setStartup("route", route)}
|
||||
/>
|
||||
<Show when={startup.visible}>
|
||||
<div
|
||||
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
|
||||
classList={{ "pointer-events-none opacity-0": startup.ready }}
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget || !startup.ready) return
|
||||
setStartup("visible", false)
|
||||
}}
|
||||
>
|
||||
<LoadingSplash deep={startup.route.type === "draft"} />
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
const [firstLaunch] = createResource(() =>
|
||||
props.api.isFirstLaunchOnboardingPending().catch((error) => {
|
||||
console.error("[desktop-onboarding] first launch check failed", error)
|
||||
return false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopWindow(props: {
|
||||
api: ElectronAPI
|
||||
updater: UpdaterPlatform
|
||||
windowState: DesktopWindowState
|
||||
onReady: () => void
|
||||
onRoute: (route: LayoutRoute) => void
|
||||
}) {
|
||||
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
|
||||
const platform = createDesktopPlatform(props.api, windowState, props.updater)
|
||||
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [locale] = createResource(() => preloadStoredLocale(platform))
|
||||
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
|
||||
const router = (routerProps: BaseRouterProps) => (
|
||||
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
|
||||
const [initialRoute] = createResource(
|
||||
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
|
||||
preloadRoute,
|
||||
)
|
||||
const router = (routerProps: BaseRouterProps) => <DesktopMemoryRouter {...routerProps} windowID={windowState.id} />
|
||||
const readyToReveal = () =>
|
||||
startup.ready &&
|
||||
(!import.meta.env.OPENCODE_TEST_ONBOARDING || !firstLaunch() || initialUrl !== "/" || startup.drawingReady)
|
||||
|
||||
// Reveal only after the theme and the first-launch splash choice are both resolved.
|
||||
createEffect(() => {
|
||||
if (!startup.themeReady || firstLaunch.loading) return
|
||||
void props.api.themeReady()
|
||||
})
|
||||
|
||||
function ReadyApp() {
|
||||
const wslServers = useWslServers()
|
||||
@@ -91,7 +79,13 @@ function DesktopWindow(props: {
|
||||
const sshConnections = createSshConnections(props.api.sshServers)
|
||||
const language = useLanguage()
|
||||
const ready = createMemo(
|
||||
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading && !ssh.loading,
|
||||
() =>
|
||||
!firstLaunch.loading &&
|
||||
!defaultServer.loading &&
|
||||
!sidecar.loading &&
|
||||
!locale.loading &&
|
||||
!wslServers.isLoading &&
|
||||
!ssh.loading,
|
||||
)
|
||||
const servers = createMemo(() => {
|
||||
const data = initializationData(sidecar)
|
||||
@@ -119,14 +113,16 @@ function DesktopWindow(props: {
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<DesktopStartupReady
|
||||
routeReady={() => !initialRoute.loading}
|
||||
onReady={props.onReady}
|
||||
onRoute={props.onRoute}
|
||||
routeReady={!initialRoute.loading && startup.onboardingReady}
|
||||
onReady={() => setStartup("ready", true)}
|
||||
onRoute={(route) => setStartup("route", route)}
|
||||
/>
|
||||
<DesktopFirstLaunchOnboarding
|
||||
api={props.api}
|
||||
initialUrl={getLastActiveUrl(props.windowState.id)}
|
||||
initialUrl={initialUrl}
|
||||
serverKey={key}
|
||||
pending={firstLaunch() ?? false}
|
||||
onReady={() => setStartup("onboardingReady", true)}
|
||||
/>
|
||||
<DesktopEffects api={props.api} />
|
||||
<Suspense fallback={null}>
|
||||
@@ -148,17 +144,36 @@ function DesktopWindow(props: {
|
||||
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
|
||||
onThemeApplied={(mode, scheme) => {
|
||||
void props.api.setTitlebar({ mode, scheme })
|
||||
void props.api.themeReady()
|
||||
setStartup("themeReady", true)
|
||||
}}
|
||||
>
|
||||
<Show when={true}>{(_) => <ReadyApp />}</Show>
|
||||
<Show when={!firstLaunch.loading && startup.visible}>
|
||||
<div
|
||||
data-component="startup-overlay"
|
||||
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
|
||||
classList={{ "pointer-events-none opacity-0": readyToReveal() }}
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget || !readyToReveal()) return
|
||||
setStartup("visible", false)
|
||||
}}
|
||||
>
|
||||
<LoadingSplash
|
||||
firstLaunch={!!firstLaunch() && initialUrl === "/"}
|
||||
deep={startup.route.type === "draft"}
|
||||
platform={platform}
|
||||
preview={import.meta.env.OPENCODE_TEST_ONBOARDING}
|
||||
onDrawEnd={() => setStartup("drawingReady", true)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopStartupReady(props: {
|
||||
routeReady: () => boolean
|
||||
routeReady: boolean
|
||||
onReady: () => void
|
||||
onRoute: (route: LayoutRoute) => void
|
||||
}) {
|
||||
@@ -166,7 +181,7 @@ function DesktopStartupReady(props: {
|
||||
const route = useCurrentRoute()
|
||||
createEffect(() => props.onRoute(route()))
|
||||
createEffect(() => {
|
||||
if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return
|
||||
if (!props.routeReady || !tabs.ready() || !tabs.infoReady()) return
|
||||
props.onReady()
|
||||
})
|
||||
return null
|
||||
|
||||
+4
@@ -1,6 +1,10 @@
|
||||
import type { ElectronNative } from "../preload/types"
|
||||
|
||||
declare global {
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_TEST_ONBOARDING: boolean
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electron: ElectronNative
|
||||
__OPENCODE__?: {
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import { ServerConnection, useServers, useTabs } from "@opencode/app/desktop"
|
||||
import { onMount } from "solid-js"
|
||||
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
|
||||
import { createResource } from "solid-js"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
|
||||
export function DesktopFirstLaunchOnboarding(props: {
|
||||
api: ElectronAPI
|
||||
serverKey: ServerConnection.Key
|
||||
initialUrl: string
|
||||
pending: boolean
|
||||
onReady: () => void
|
||||
}) {
|
||||
const server = useServers()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const route = useCurrentRoute()
|
||||
|
||||
onMount(() => {
|
||||
void runFirstLaunchOnboarding()
|
||||
const [completed] = createResource(async () => {
|
||||
await runFirstLaunchOnboarding()
|
||||
return null
|
||||
})
|
||||
|
||||
async function runFirstLaunchOnboarding() {
|
||||
try {
|
||||
const pending = await props.api.isFirstLaunchOnboardingPending()
|
||||
if (!pending) return
|
||||
if (!props.pending) return
|
||||
|
||||
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
|
||||
|
||||
const shouldTrigger =
|
||||
props.initialUrl === "/" && tabs.store.length === 0 && server.list.every(ServerConnection.builtin)
|
||||
props.initialUrl === "/" &&
|
||||
route().type === "home" &&
|
||||
tabs.store.length === 0 &&
|
||||
server.list.every(ServerConnection.builtin)
|
||||
|
||||
console.info("[desktop-onboarding] first launch onboarding evaluated", {
|
||||
pending,
|
||||
pending: props.pending,
|
||||
shouldTrigger,
|
||||
initialUrl: props.initialUrl,
|
||||
tabs: tabs.store.length,
|
||||
@@ -39,11 +46,18 @@ export function DesktopFirstLaunchOnboarding(props: {
|
||||
const projects = server.projects.forServer(props.serverKey)
|
||||
projects.open(directory)
|
||||
projects.touch(directory)
|
||||
const connection = server.list.find((connection) => ServerConnection.key(connection) === props.serverKey)
|
||||
if (connection) {
|
||||
const data = global.ensureServerCtx(connection).data
|
||||
// Load the initial provider/model state before the draft transition exposes the composer.
|
||||
await Promise.all([data.location.provider.sync({ directory }), data.location.model.sync({ directory })])
|
||||
}
|
||||
tabs.select(await tabs.newDraft({ server: props.serverKey, directory }))
|
||||
} catch (error) {
|
||||
console.error("[desktop-onboarding] first launch onboarding failed", error)
|
||||
} finally {
|
||||
props.onReady()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
// Let startup failures reach the app's recovery screen, including its splash boundary.
|
||||
return <>{completed()}</>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user