Compare commits

...
Author SHA1 Message Date
LukeParkerDev 4f474c942a feat(app): attach any file and deliver unsupported ones by path
Drag, paste, and pick no longer reject files the model cannot read.
On submit each attachment is delivered inline when the selected model
reads it natively; otherwise the prompt references it by path: the
source path on a local server, or a copy staged under the server tmp
directory (from /api/info) via fs.write on a remote one. Removes the
accepted-type filters and the unsupported-attachment toast.
2026-09-17 14:45:40 +10:00
LukeParkerDev ec197eb4e4 feat(server): add fs.write endpoint
Clients can write base64 content to an absolute path or a path relative
to the requested location. Parent directories are created and the
resolved absolute path is returned. Unlike fs.read, the target is not
confined to the location so clients can stage files in the server tmp
directory reported by /api/info, which the model already prefers and is
permitted to access.

Also compares the canonical tmp path in the service info test: on
Windows CI os.tmpdir() is an 8.3 short name that the server realpaths.
2026-09-17 14:45:30 +10:00
opencode-agent[bot]andkitlangton fca4a8fa3b feat(tui): show explicit subagent model (#49388)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-09-16 22:50:28 -05:00
Aiden Cline 8520617ca8 fix(core): retry provider failures for about 84s with 10s gap cap (#49441) 2026-09-16 20:59:16 -05:00
Dax Raad ab3566ab82 feat(server): expose server info endpoint 2026-09-16 21:43:58 -04:00
Dax Raad b642c1d9ea docs: add CLI settings reference 2026-09-16 21:18:35 -04:00
David HillandLukeParkerDev c8cc984aa0 feat(app): improve project settings and actions (#49423)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-17 00:39:21 +00:00
Luke Parker 5a9448acbb fix(app): retry transport errors wrapped by the client (#49426) 2026-09-17 10:34:52 +10:00
David Hill f6604cd367 feat(desktop): add animated first-launch loading screen (#49408) 2026-09-17 10:02:09 +10:00
Luke Parker acfacede2d fix(app): preserve native clipboard paste (#49424) 2026-09-17 09:47:00 +10:00
Aiden Cline 4a27842fe6 fix(ai): classify gateway account limits as quota and keep 4xx non-retryable (#49195) 2026-09-16 16:14:09 -05:00
Aiden Cline d797722187 feat(codemode): carry cause and own data across the error boundary (#49390) 2026-09-16 15:58:05 -05:00
Dax 7390832f13 feat(tui): reload all locations
Add a global location reload endpoint, shared shutdown lifecycle, client resynchronization, and the TUI /reload command.\n\nfrom OpenCode
2026-09-16 16:47:44 -04:00
Kit Langton 7689c3654e fix(tui): hide error hint when MCP Enter starts sign-in (#49403) 2026-09-16 19:33:08 +00:00
Dax 7df0935ada fix(tui): apply model selection on blank submit (#49374)
Apply the selected model on blank Enter and simplify shared prompt submission setup and recovery.\n\nfrom OpenCode
2026-09-16 19:32:45 +00:00
Dax bcd43760df feat(cli): support inline config content (#49399) 2026-09-16 19:18:55 +00:00
Aiden Cline 9073c522ef feat(plugin): add experimental WebSocket send and receive hooks (#49136) 2026-09-16 14:13:33 -05:00
opencode-agent[bot]andrekram1-node 04c296310e fix(core): skip session warming for subagents (#49387)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-16 13:58:40 -05:00
220 changed files with 2874 additions and 1121 deletions
+2 -2
View File
@@ -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] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `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. |
+21 -6
View File
@@ -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,
+3 -3
View File
@@ -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."}}')
}),
)
+47 -3
View File
@@ -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)
+1 -1
View File
@@ -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" })),
+7 -1
View File
@@ -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({
@@ -4,64 +4,6 @@ import { createBlobReference } from "@/runtime/persistence/drafts"
import { uuid } from "@/runtime/persistence/uuid"
import type { ComposerAttachment, ComposerPrompt } from "../types"
const accepted = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
type PromptTarget = {
current: () => ComposerPrompt
cursor: () => number | undefined
@@ -75,7 +17,6 @@ export type ComposerAttachmentConfig = {
) => Promise<void>
directory: () => string
isDialogActive: () => boolean
warn: () => void
duplicate: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
@@ -102,13 +43,9 @@ export function createComposerAttachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
const add = async (file: File, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
if (toast) input.warn()
return false
}
const blob = input.store ? await input.store(file) : await createBlobReference(file)
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
@@ -138,13 +75,11 @@ export function createComposerAttachments(
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachments = async (files: File[], toast = true, target = capture()) => {
const found = await files.reduce(async (result, file) => {
const addAttachments = async (files: File[], target = capture()) => {
return files.reduce(async (result, file) => {
const previous = await result
return (await add(file, false, target)) || previous
return (await add(file, target)) || previous
}, Promise.resolve(false))
if (!found && files.length > 0 && toast) input.warn()
return found
}
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
@@ -159,13 +94,13 @@ export function createComposerAttachments(
return file ? [file] : []
})
if (files.length > 0) {
await addAttachments(files, true, target)
await addAttachments(files, target)
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file && (await add(file, true, target, true))) return
if (file && (await add(file, target, true))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
@@ -223,9 +158,7 @@ export function createComposerAttachments(
fallback()
return
}
void input
.picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
.catch(input.onError)
void input.picker({ defaultPath: input.directory(), multiple: true }, (file) => add(file)).catch(input.onError)
},
}
}
@@ -249,6 +182,8 @@ const textMimes = new Set([
"application/yaml",
])
// Text-like files normalize to text/plain so the server inlines their content; every other
// file keeps a binary type and is delivered to the model by path or as native media.
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
@@ -259,10 +194,11 @@ async function attachmentMime(file: File) {
if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
return "text/plain"
}
const binary = type || "application/octet-stream"
const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer())
if (bytes.some((byte) => byte === 0)) return
if (bytes.some((byte) => byte === 0)) return binary
const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
if (bytes.length > 0 && control / bytes.length > 0.3) return
if (bytes.length > 0 && control / bytes.length > 0.3) return binary
return "text/plain"
}
@@ -0,0 +1,70 @@
import type { Accessor } from "solid-js"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
import type { ImageAttachmentPart } from "../state"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Copies a file into the server's temporary directory and returns its absolute path there. */
upload: (file: { name: string; data: string }) => Promise<string>
}
export type DeliveredAttachment =
| { type: "inline"; attachment: ImageAttachmentPart; dataUrl: string }
| { type: "path"; attachment: ImageAttachmentPart; path: string }
// An attachment travels inline when the model reads its bytes natively. Anything else reaches
// the model as a path on the server, which its tools can open, instead of being rejected.
export function deliverAttachments(attachments: ImageAttachmentPart[], destination: AttachmentDestination) {
return Promise.all(attachments.map((attachment) => deliver(attachment, destination)))
}
async function deliver(
attachment: ImageAttachmentPart,
destination: AttachmentDestination,
): Promise<DeliveredAttachment> {
if (native(attachment.mime, destination.input)) {
return { type: "inline", attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) }
}
if (destination.local && attachment.sourcePath) return { type: "path", attachment, path: attachment.sourcePath }
const dataUrl = await blobDataUrl(attachment.blob, attachment.mime)
const path = await destination.upload({ name: attachment.filename, data: dataUrl.slice(dataUrl.indexOf(",") + 1) })
return { type: "path", attachment, path }
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file) => {
const info = await sdk.api.server.info()
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const written = await sdk.api.file.write({
location: { directory: location().directory },
payload: { path: `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`, data: file.data },
})
return written.data.path
},
})
}
@@ -167,7 +167,7 @@ function ComposerStory(props: {
? buildPromptRequest({
prompt: draft.prompt,
context: draft.context.items,
images: [],
attachments: [],
text: value,
sessionDirectory: "C:/repo",
})
@@ -102,7 +102,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
ref={props.controller.setFileInput}
type="file"
multiple
accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*,application/json,application/ld+json,application/toml,application/x-toml,application/x-yaml,application/xml,application/yaml,.c,.cc,.cjs,.conf,.cpp,.css,.csv,.cts,.env,.go,.gql,.graphql,.h,.hh,.hpp,.htm,.html,.ini,.java,.js,.json,.jsx,.log,.md,.mdx,.mjs,.mts,.py,.rb,.rs,.sass,.scss,.sh,.sql,.toml,.ts,.tsx,.txt,.xml,.yaml,.yml,.zsh"
class="hidden"
onChange={(event) => {
const list = event.currentTarget.files
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: 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
+2 -5
View File
@@ -22,6 +22,7 @@ import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/deliver"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -265,6 +266,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
destination: useAttachmentDestination(adapter.controls),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
@@ -339,11 +341,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
isDialogActive: () => !!dialog.active,
warn: () =>
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
+27 -34
View File
@@ -1,8 +1,17 @@
import { describe, expect, test } from "bun:test"
import { Skill } from "@opencode/schema/skill"
import type { Prompt } from "@/composer/state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import type { DeliveredAttachment } from "./attachments/deliver"
import { buildPromptRequest } from "./request"
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>): DeliveredAttachment {
return {
type: "inline",
attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra },
dataUrl: `data:${mime};base64,AAA`,
}
}
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
const prompt: Prompt = [
@@ -21,9 +30,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
attachments: [inline("a.png", "image/png")],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -45,16 +52,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
text: "check these",
sessionDirectory: "/repo",
})
@@ -69,15 +67,10 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
attachments: [
inline("opencode.global.dat", "text/plain", {
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
}),
],
text: "inspect this",
sessionDirectory: "C:\\Repos\\sst\\opencode",
@@ -102,7 +95,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
@@ -124,7 +117,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
attachments: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
@@ -146,7 +139,7 @@ describe("buildPromptRequest", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
attachments: [],
text: "look",
sessionDirectory: "/repo",
})
@@ -162,7 +155,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
@@ -183,7 +176,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
@@ -204,7 +197,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
@@ -218,7 +211,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
@@ -233,7 +226,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
attachments: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
@@ -255,7 +248,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
@@ -282,7 +275,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
@@ -307,7 +300,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
images: [],
attachments: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
@@ -337,7 +330,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
images: [],
attachments: [],
text: "@review",
sessionDirectory: "/repo",
})
+16 -10
View File
@@ -1,8 +1,9 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
import type { DeliveredAttachment } from "@/composer/attachments/deliver"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
@@ -28,7 +29,7 @@ type ContextFile = {
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
attachments: DeliveredAttachment[]
text: string
sessionDirectory: string
}
@@ -106,16 +107,21 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
return [file, ...mentions]
})
const images = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
const inline = input.attachments.flatMap((item) =>
item.type === "inline"
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
: [],
)
// Path references are part of what the user sends, so they stay visible in the message.
const body = [
...(input.text.trim() ? [input.text] : []),
...input.attachments.flatMap((item) => (item.type === "path" ? [`Attached file: \`${item.path}\``] : [])),
].join("\n")
return {
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
text: [...(body ? [body] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: body,
files: [...files, ...context, ...inline],
agents,
skills,
comments,
+10
View File
@@ -3,6 +3,7 @@ import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import type { AttachmentDestination } from "./attachments/deliver"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -48,6 +49,14 @@ function controls(): ComposerControls {
}
}
const destination: AttachmentDestination = {
input: { image: true, pdf: true },
local: false,
upload: async () => {
throw new Error("native attachments must not upload")
},
}
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
@@ -64,6 +73,7 @@ function submitInput(
resetHistory() {},
setMode() {},
closePopover() {},
destination: () => destination,
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
+30 -20
View File
@@ -8,7 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -34,6 +34,7 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
destination: () => AttachmentDestination
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
@@ -86,10 +87,16 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
const sending = sendPrompt(
session,
value,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
() => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
},
).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -122,9 +129,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
value,
command,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -293,9 +304,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
destination: AttachmentDestination,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
@@ -334,10 +346,11 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
const request = await buildSubmissionRequest(session, value, destination)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
@@ -370,21 +383,18 @@ async function sendPrompt(
await sending
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const request = buildPromptRequest({
async function buildSubmissionRequest(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
) {
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
images,
attachments: await deliverAttachments(value.images, destination),
text: value.text,
sessionDirectory: session.directory,
})
return request
}
function failSubmission(
+1 -1
View File
@@ -1,5 +1,4 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
@@ -15,6 +14,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"
-2
View File
@@ -371,8 +371,6 @@ export const dict = {
"prompt.attachment.remove": "ዓባሪን አስወግድ",
"prompt.action.send": "ላክ",
"prompt.action.stop": "አቁም",
"prompt.toast.pasteUnsupported.title": "የማይደገፍ ዓባሪ",
"prompt.toast.pasteUnsupported.description": "ምስሎች፣ ፒዲኤፎች ወይም የጽሑፍ ፋይሎች ብቻ እዚህ ጋር ሊጣመሩ ይችላሉ።",
"prompt.toast.attachmentDuplicate.title": "ይህ ፋይል አስቀድሞ ተሰቅሏል",
"prompt.toast.modelAgentRequired.title": "ወኪል እና ሞዴል ይምረጡ",
"prompt.toast.modelAgentRequired.description": "ፕሮምፕት ከመላክዎ በፊት ወኪል እና ሞዴል ይምረጡ።",
-2
View File
@@ -380,9 +380,7 @@ export const dict = {
"prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال",
"prompt.action.stop": "إيقاف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
"prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",
-2
View File
@@ -378,8 +378,6 @@ export const dict = {
"prompt.attachment.remove": "Əlavəni sil",
"prompt.action.send": "Göndər",
"prompt.action.stop": "Dayandır",
"prompt.toast.pasteUnsupported.title": "Dəstəklənməyən əlavə",
"prompt.toast.pasteUnsupported.description": "Buraya yalnız şəkillər, PDF-lər və ya mətn faylları əlavə edilə bilər.",
"prompt.toast.attachmentDuplicate.title": "Bu fayl artıq yüklənib",
"prompt.toast.modelAgentRequired.title": "Agent və model seçin",
"prompt.toast.modelAgentRequired.description": "Prompt göndərməzdən əvvəl agent və model seçin.",
-2
View File
@@ -378,8 +378,6 @@ export const dict = {
"prompt.attachment.remove": "Премахване на прикачения файл",
"prompt.action.send": "Изпратете",
"prompt.action.stop": "Спрете",
"prompt.toast.pasteUnsupported.title": "Неподдържан прикачен файл",
"prompt.toast.pasteUnsupported.description": "Тук могат да се прикачват само изображения, PDF или текстови файлове.",
"prompt.toast.attachmentDuplicate.title": "Този файл вече е качен",
"prompt.toast.modelAgentRequired.title": "Изберете агент и модел",
"prompt.toast.modelAgentRequired.description": "Изберете агент и модел, преди да изпратите подкана.",
-2
View File
@@ -375,8 +375,6 @@ export const dict: Record<string, string> = {
"prompt.attachment.remove": "সংযুক্তি সরান",
"prompt.action.send": "পাঠান",
"prompt.action.stop": "থামো",
"prompt.toast.pasteUnsupported.title": "অসমর্থিত সংযুক্তি",
"prompt.toast.pasteUnsupported.description": "এখানে শুধুমাত্র ছবি, পিডিএফ বা টেক্সট ফাইল সংযুক্ত করা যাবে।",
"prompt.toast.attachmentDuplicate.title": "এই ফাইল ইতিমধ্যে আপলোড করা হয়েছে",
"prompt.toast.modelAgentRequired.title": "একটি এজেন্ট এবং মডেল নির্বাচন করুন",
"prompt.toast.modelAgentRequired.description": "প্রম্পট পাঠানোর আগে একটি এজেন্ট এবং মডেল বেছে নিন।",
-2
View File
@@ -382,9 +382,7 @@ export const dict = {
"prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
"prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",
-2
View File
@@ -403,9 +403,7 @@ export const dict = {
"prompt.action.send": "Pošalji",
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
"prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",
-2
View File
@@ -377,8 +377,6 @@ export const dict = {
"prompt.attachment.remove": "Elimina el fitxer adjunt",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Atureu-vos",
"prompt.toast.pasteUnsupported.title": "Fitxer adjunt no compatible",
"prompt.toast.pasteUnsupported.description": "Aquí només es poden adjuntar imatges, PDFs o fitxers de text.",
"prompt.toast.attachmentDuplicate.title": "Aquest fitxer ja s'ha penjat",
"prompt.toast.modelAgentRequired.title": "Seleccioneu un agent i un model",
"prompt.toast.modelAgentRequired.description": "Trieu un agent i un model abans d'enviar una sol·licitud.",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Odstraňte přílohu",
"prompt.action.send": "Odeslat",
"prompt.action.stop": "Přestaň",
"prompt.toast.pasteUnsupported.title": "Nepodporovaná příloha",
"prompt.toast.pasteUnsupported.description": "Zde lze připojit pouze obrázky, PDFs nebo textové soubory.",
"prompt.toast.attachmentDuplicate.title": "Tento soubor již byl nahrán",
"prompt.toast.modelAgentRequired.title": "Vyberte agenta a model",
"prompt.toast.modelAgentRequired.description": "Před odesláním výzvy vyberte zástupce a model.",
-2
View File
@@ -300,9 +300,7 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",
-2
View File
@@ -286,9 +286,7 @@ export const dict = {
"prompt.attachment.remove": "Anhang entfernen",
"prompt.action.send": "Senden",
"prompt.action.stop": "Stoppen",
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description":
"Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",
-3
View File
@@ -380,9 +380,6 @@ export const dict = {
"prompt.attachment.remove": "އެޓޭޗްމަންޓް ނަގާށެވެ",
"prompt.action.send": "ފޮނުވުން",
"prompt.action.stop": "ހުއްޓުން",
"prompt.toast.pasteUnsupported.title": "ސަޕޯޓް ނުކުރާ އެޓޭޗްމަންޓެވެ",
"prompt.toast.pasteUnsupported.description":
"މިތަނުގައި އެޓޭޗް ކުރެވޭނީ ހަމައެކަނި ތަސްވީރު، PDFs، ނުވަތަ ޓެކްސްޓް ފައިލްތަކެވެ.",
"prompt.toast.attachmentDuplicate.title": "މި ފައިލް މިހާރު ވަނީ އަޕްލޯޑްކޮށްފައެވެ",
"prompt.toast.modelAgentRequired.title": "އޭޖެންޓަކާއި މޮޑެލްއެއް ހޮވުން",
"prompt.toast.modelAgentRequired.description": "ޕްރޮމްޕްޓެއް ފޮނުވުމުގެ ކުރިން އޭޖެންޓަކާއި މޮޑެލްއެއް ހޮވުން.",
-3
View File
@@ -379,9 +379,6 @@ export const dict: Record<string, string> = {
"prompt.attachment.remove": "མཉམ་སྦྲགས་རྩ་བསྐྲད་གཏང་།",
"prompt.action.send": "བཏང༌ནི",
"prompt.action.stop": "བཀག་པ",
"prompt.toast.pasteUnsupported.title": "རྒྱབ་སྐྱོར་མེད་པའི་མཉམ་སྦྲགས།",
"prompt.toast.pasteUnsupported.description":
"པར་རིས་དང་པི་ཌི་ཨེཕ་ ཡང་ན་ ཚིག་ཡིག་ཡིག་སྣོད་ཚུ་རྐྱངམ་ཅིག་ ནཱ་ལུ་མཉམ་སྦྲགས་འབད་བཏུབ།",
"prompt.toast.attachmentDuplicate.title": "ཡིག་སྣོད་འདི་ཧེ་མ་ལས་སྐྱེལ་བཙུགས་འབད་ཡི།",
"prompt.toast.modelAgentRequired.title": "ལས་ཚབ་དང་དཔེ་ཚད་ཅིག་སེལ་འཐུ་འབད།",
"prompt.toast.modelAgentRequired.description": "བརྡ་སྟོན་མ་གཏང་པའི་ཧེ་མ་ ལས་ཚབ་དང་དཔེ་ཚད་གདམ་ཁ་རྐྱབས།",
-2
View File
@@ -376,8 +376,6 @@ export const dict = {
"prompt.attachment.remove": "Κατάργηση συνημμένου",
"prompt.action.send": "Αποστολή",
"prompt.action.stop": "Διακοπή",
"prompt.toast.pasteUnsupported.title": "Μη υποστηριζόμενο συνημμένο",
"prompt.toast.pasteUnsupported.description": "Εδώ επισυνάπτονται μόνο εικόνες, αρχεία PDF ή αρχεία κειμένου.",
"prompt.toast.attachmentDuplicate.title": "Αυτό το αρχείο έχει ήδη μεταφορτωθεί",
"prompt.toast.modelAgentRequired.title": "Επιλέξτε έναν πράκτορα και μοντέλο",
"prompt.toast.modelAgentRequired.description":
-2
View File
@@ -365,8 +365,6 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
-3
View File
@@ -403,10 +403,7 @@ export const dict = {
"prompt.action.send": "Enviar",
"prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
"prompt.toast.pasteUnsupported.description":
"Aquí solo se pueden adjuntar imágenes, archivos PDF o archivos de texto.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
"prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "Eemalda manus",
"prompt.action.send": "Saada",
"prompt.action.stop": "Peatus",
"prompt.toast.pasteUnsupported.title": "Toetamata manus",
"prompt.toast.pasteUnsupported.description": "Siia saab lisada ainult pilte, PDFs või tekstifaile.",
"prompt.toast.attachmentDuplicate.title": "See fail on juba üles laaditud",
"prompt.toast.modelAgentRequired.title": "Valige agent ja mudel",
"prompt.toast.modelAgentRequired.description": "Enne viipa saatmist valige agent ja mudel.",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "حذف پیوست",
"prompt.action.send": "ارسال کنید",
"prompt.action.stop": "توقف کنید",
"prompt.toast.pasteUnsupported.title": "پیوست پشتیبانی نشده است",
"prompt.toast.pasteUnsupported.description": "فقط تصاویر، PDFs، یا فایل های متنی را می توان در اینجا پیوست کرد.",
"prompt.toast.attachmentDuplicate.title": "این فایل قبلا آپلود شده است",
"prompt.toast.modelAgentRequired.title": "یک عامل و مدل را انتخاب کنید",
"prompt.toast.modelAgentRequired.description": "قبل از ارسال درخواست، یک عامل و مدل را انتخاب کنید.",
-2
View File
@@ -282,8 +282,6 @@ export const dict = {
"prompt.attachment.remove": "Poista liite",
"prompt.action.send": "Lähetä",
"prompt.action.stop": "Pysäytä",
"prompt.toast.pasteUnsupported.title": "Liitettä ei tueta",
"prompt.toast.pasteUnsupported.description": "Vain kuvia, PDF-tiedostoja tai tekstitiedostoja voi liittää tähän.",
"prompt.toast.attachmentDuplicate.title": "Tämä tiedosto on jo ladattu",
"prompt.toast.modelAgentRequired.title": "Valitse agentti ja malli",
"prompt.toast.modelAgentRequired.description": "Valitse agentti ja malli ennen kehotteen lähettämistä.",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "Strika viðheftið",
"prompt.action.send": "Send",
"prompt.action.stop": "Steðga",
"prompt.toast.pasteUnsupported.title": "Óstuðlað viðhefti",
"prompt.toast.pasteUnsupported.description": "Bert myndir, PDFs, ella tekstfílur kunnu viðheftast her.",
"prompt.toast.attachmentDuplicate.title": "Hendan fílan er longu løgd upp.",
"prompt.toast.modelAgentRequired.title": "Vel agent og modell",
"prompt.toast.modelAgentRequired.description": "Vel agent og modell, áðrenn tú sendir ein prompt.",
-3
View File
@@ -385,10 +385,7 @@ export const dict = {
"prompt.attachment.remove": "Supprimer la pièce jointe",
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
"prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
"prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer une invite.",
"prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail",
-2
View File
@@ -373,8 +373,6 @@ export const dict = {
"prompt.attachment.remove": "הסר את הקובץ המצורף",
"prompt.action.send": "שלח",
"prompt.action.stop": "עצור",
"prompt.toast.pasteUnsupported.title": "קובץ מצורף לא נתמך",
"prompt.toast.pasteUnsupported.description": "ניתן לצרף כאן רק תמונות, קובצי PDF או קבצי טקסט.",
"prompt.toast.attachmentDuplicate.title": "הקובץ הזה כבר הועלה",
"prompt.toast.modelAgentRequired.title": "בחר סוכן ומודל",
"prompt.toast.modelAgentRequired.description": "יש לבחור סוכן ומודל לפני שליחת פרומפט.",
-2
View File
@@ -382,8 +382,6 @@ export const dict = {
"prompt.attachment.remove": "अनुलग्नक हटाएँ",
"prompt.action.send": "भेजें",
"prompt.action.stop": "रोकें",
"prompt.toast.pasteUnsupported.title": "असमर्थित अनुलग्नक",
"prompt.toast.pasteUnsupported.description": "यहां केवल छवियां, PDFs, या टेक्स्ट फ़ाइलें संलग्न की जा सकती हैं।",
"prompt.toast.attachmentDuplicate.title": "यह फ़ाइल पहले ही अपलोड की जा चुकी है",
"prompt.toast.modelAgentRequired.title": "एक एजेंट और मॉडल चुनें",
"prompt.toast.modelAgentRequired.description": "प्रॉम्प्ट भेजने से पहले एक एजेंट और मॉडल चुनें।",
-2
View File
@@ -379,8 +379,6 @@ export const dict = {
"prompt.attachment.remove": "Ukloni privitak",
"prompt.action.send": "Poslati",
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržani privitak",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"prompt.toast.modelAgentRequired.title": "Odaberite agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberite agenta i model prije slanja upita.",
-2
View File
@@ -379,8 +379,6 @@ export const dict = {
"prompt.attachment.remove": "Távolítsa el a mellékletet",
"prompt.action.send": "Elküld",
"prompt.action.stop": "Leállítás",
"prompt.toast.pasteUnsupported.title": "Nem támogatott melléklet",
"prompt.toast.pasteUnsupported.description": "Ide csak képeket, PDF-eket vagy szöveges fájlokat lehet csatolni.",
"prompt.toast.attachmentDuplicate.title": "Ezt a fájlt már feltöltötték",
"prompt.toast.modelAgentRequired.title": "Válasszon egy ügynököt és modellt",
"prompt.toast.modelAgentRequired.description": "A felszólítás elküldése előtt válasszon ügynököt és modellt.",
-2
View File
@@ -377,8 +377,6 @@ export const dict = {
"prompt.attachment.remove": "Հեռացնել հավելվածը",
"prompt.action.send": "Ուղարկել",
"prompt.action.stop": "Կանգնեցնել",
"prompt.toast.pasteUnsupported.title": "Չաջակցվող հավելված",
"prompt.toast.pasteUnsupported.description": "Այստեղ կարող են կցվել միայն պատկերներ, PDF կամ տեքստային ֆայլեր։",
"prompt.toast.attachmentDuplicate.title": "Այս ֆայլն արդեն վերբեռնվել է",
"prompt.toast.modelAgentRequired.title": "Ընտրեք գործակալ և մոդել",
"prompt.toast.modelAgentRequired.description": "Ընտրեք գործակալ և մոդել՝ նախքան հուշում ուղարկելը։",
-2
View File
@@ -403,8 +403,6 @@ export const dict = {
"prompt.action.send": "Kirim",
"prompt.action.stop": "Hentikan",
"prompt.toast.pasteUnsupported.title": "Lampiran tidak didukung",
"prompt.toast.pasteUnsupported.description": "Hanya gambar, PDF, atau berkas teks yang dapat dilampirkan di sini.",
"prompt.toast.attachmentDuplicate.title": "Berkas ini sudah diunggah",
"prompt.toast.modelAgentRequired.title": "Pilih agen dan model",
"prompt.toast.modelAgentRequired.description": "Pilih agen dan model sebelum mengirim prompt.",
-2
View File
@@ -379,8 +379,6 @@ export const dict = {
"prompt.attachment.remove": "Fjarlægðu viðhengi",
"prompt.action.send": "Senda",
"prompt.action.stop": "Stöðva",
"prompt.toast.pasteUnsupported.title": "Óstudd viðhengi",
"prompt.toast.pasteUnsupported.description": "Aðeins er hægt að hengja myndir, PDF-skjöl eða textaskrár hér við.",
"prompt.toast.attachmentDuplicate.title": "Þessari skrá hefur þegar verið hlaðið upp",
"prompt.toast.modelAgentRequired.title": "Veldu fulltrúa og líkan",
"prompt.toast.modelAgentRequired.description": "Veldu fulltrúa og líkan áður en þú sendir kvaðningu.",
-2
View File
@@ -284,8 +284,6 @@ export const dict = {
"prompt.attachment.remove": "Rimuovi l'allegato",
"prompt.action.send": "Invia",
"prompt.action.stop": "Interrompi",
"prompt.toast.pasteUnsupported.title": "Allegato non supportato",
"prompt.toast.pasteUnsupported.description": "Qui è possibile allegare solo immagini, PDF o file di testo.",
"prompt.toast.attachmentDuplicate.title": "Questo file è già stato caricato",
"prompt.toast.modelAgentRequired.title": "Seleziona un agente e un modello",
"prompt.toast.modelAgentRequired.description": "Scegli un agente e un modello prima di inviare un prompt.",
-2
View File
@@ -379,9 +379,7 @@ export const dict = {
"prompt.attachment.remove": "添付ファイルを削除",
"prompt.action.send": "送信",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
"prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています",
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
"prompt.toast.worktreeCreateFailed.title": "ワークツリーの作成に失敗しました",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "დანართის წაშლა",
"prompt.action.send": "გაგზავნა",
"prompt.action.stop": "შეჩერება",
"prompt.toast.pasteUnsupported.title": "მხარდაუჭერელი დანართი",
"prompt.toast.pasteUnsupported.description": "აქ შეიძლება დაერთოს მხოლოდ სურათები, PDF ან ტექსტური ფაილები.",
"prompt.toast.attachmentDuplicate.title": "ეს ფაილი უკვე ატვირთულია",
"prompt.toast.modelAgentRequired.title": "აირჩიეთ აგენტი და მოდელი",
"prompt.toast.modelAgentRequired.description": "აირჩიეთ აგენტი და მოდელი მოთხოვნის გაგზავნამდე.",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "លុបឯកសារភ្ជាប់ចេញ",
"prompt.action.send": "ផ្ញើ",
"prompt.action.stop": "ឈប់",
"prompt.toast.pasteUnsupported.title": "ឯកសារភ្ជាប់ដែលមិនគាំទ្រ",
"prompt.toast.pasteUnsupported.description": "មានតែរូបភាព PDF ឬឯកសារអត្ថបទប៉ុណ្ណោះដែលអាចភ្ជាប់មកទីនេះបាន។",
"prompt.toast.attachmentDuplicate.title": "ឯកសារនេះត្រូវបានផ្ទុកឡើងរួចហើយ",
"prompt.toast.modelAgentRequired.title": "ជ្រើសរើសភ្នាក់ងារ និងម៉ូដែល",
"prompt.toast.modelAgentRequired.description": "ជ្រើសរើសភ្នាក់ងារ និងម៉ូដែលមុនពេលផ្ញើប្រអប់បញ្ចូល។",
-2
View File
@@ -268,9 +268,7 @@ export const dict = {
"prompt.attachment.remove": "첨부 파일 제거",
"prompt.action.send": "전송",
"prompt.action.stop": "중지",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
"prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다",
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
"prompt.toast.worktreeCreateFailed.title": "작업 트리 생성 실패",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "ເອົາໄຟລ໌ແນບອອກ",
"prompt.action.send": "ສົ່ງ",
"prompt.action.stop": "ຢຸດ",
"prompt.toast.pasteUnsupported.title": "ບໍ່ຮອງຮັບໄຟລ໌ແນບ",
"prompt.toast.pasteUnsupported.description": "ພຽງແຕ່ຮູບພາບ, PDFs, ຫຼືໄຟລ໌ຂໍ້ຄວາມສາມາດຕິດຢູ່ນີ້.",
"prompt.toast.attachmentDuplicate.title": "ໄຟລ໌ນີ້ໄດ້ຖືກອັບໂຫລດໄປກ່ອນແລ້ວ",
"prompt.toast.modelAgentRequired.title": "ເລືອກຕົວແທນ ແລະຕົວແບບ",
"prompt.toast.modelAgentRequired.description": "ເລືອກຕົວແທນ ແລະຕົວແບບກ່ອນສົ່ງ prompt.",
-2
View File
@@ -380,8 +380,6 @@ export const dict = {
"prompt.attachment.remove": "Pašalinti priedą",
"prompt.action.send": "Siųsti",
"prompt.action.stop": "Stabdyti",
"prompt.toast.pasteUnsupported.title": "Nepalaikomas priedas",
"prompt.toast.pasteUnsupported.description": "Čia galima pridėti tik vaizdus, PDF arba tekstinius failus.",
"prompt.toast.attachmentDuplicate.title": "Šis failas jau buvo įkeltas",
"prompt.toast.modelAgentRequired.title": "Pasirinkite agentą ir modelį",
"prompt.toast.modelAgentRequired.description": "Prieš siųsdami raginimą, pasirinkite agentą ir modelį.",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Noņemt pielikumu",
"prompt.action.send": "Sūtīt",
"prompt.action.stop": "Apturēt",
"prompt.toast.pasteUnsupported.title": "Neatbalstīts pielikums",
"prompt.toast.pasteUnsupported.description": "Šeit var pievienot tikai attēlus, PDF vai teksta failus.",
"prompt.toast.attachmentDuplicate.title": "Šis fails jau ir augšupielādēts",
"prompt.toast.modelAgentRequired.title": "Izvēlieties aģentu un modeli",
"prompt.toast.modelAgentRequired.description": "Pirms nosūtīšanas izvēlieties aģentu un modeli.",
-2
View File
@@ -376,8 +376,6 @@ export const dict = {
"prompt.attachment.remove": "Отстранете го прилогот",
"prompt.action.send": "Испрати",
"prompt.action.stop": "Стоп",
"prompt.toast.pasteUnsupported.title": "Неподдржан прилог",
"prompt.toast.pasteUnsupported.description": "Овде може да се прикачат само слики, PDFs или текстуални датотеки.",
"prompt.toast.attachmentDuplicate.title": "Оваа датотека е веќе поставена",
"prompt.toast.modelAgentRequired.title": "Изберете агент и модел",
"prompt.toast.modelAgentRequired.description": "Изберете агент и модел пред да испратите известување.",
-2
View File
@@ -378,8 +378,6 @@ export const dict = {
"prompt.attachment.remove": "Хавсралтыг устгана уу",
"prompt.action.send": "Илгээх",
"prompt.action.stop": "Зогс",
"prompt.toast.pasteUnsupported.title": "Дэмжигдээгүй хавсралт",
"prompt.toast.pasteUnsupported.description": "Энд зөвхөн зураг, PDFс, эсвэл текст файлыг хавсаргах боломжтой.",
"prompt.toast.attachmentDuplicate.title": "Энэ файлыг аль хэдийн байршуулсан байна",
"prompt.toast.modelAgentRequired.title": "Агент болон загварыг сонгоно уу",
"prompt.toast.modelAgentRequired.description": "Промпт илгээхээсээ өмнө агент болон загварыг сонгоно уу.",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Buang lampiran",
"prompt.action.send": "Hantar",
"prompt.action.stop": "Henti",
"prompt.toast.pasteUnsupported.title": "Lampiran tidak disokong",
"prompt.toast.pasteUnsupported.description": "Hanya imej, PDF, atau fail teks boleh dilampirkan di sini.",
"prompt.toast.attachmentDuplicate.title": "Fail ini telah dimuat naik",
"prompt.toast.modelAgentRequired.title": "Pilih ejen dan model",
"prompt.toast.modelAgentRequired.description": "Pilih ejen dan model sebelum menghantar prompt.",
-3
View File
@@ -378,9 +378,6 @@ export const dict = {
"prompt.attachment.remove": "ပူးတွဲပါဖိုင်ကို ဖယ်ရှားပါ။",
"prompt.action.send": "ပို့ပါ။",
"prompt.action.stop": "ရပ်ပါ။",
"prompt.toast.pasteUnsupported.title": "ပူးတွဲပါဖိုင်ကို ပံ့ပိုးမထားပါ။",
"prompt.toast.pasteUnsupported.description":
"ရုပ်ပုံများ၊ PDF များ သို့မဟုတ် စာသားဖိုင်များကိုသာ ဤနေရာတွင် ပူးတွဲနိုင်ပါသည်။",
"prompt.toast.attachmentDuplicate.title": "ဤဖိုင်ကို အပ်လုဒ်လုပ်ပြီးပါပြီ။",
"prompt.toast.modelAgentRequired.title": "အေးဂျင့်နှင့် မော်ဒယ်ကို ရွေးပါ။",
"prompt.toast.modelAgentRequired.description": "Prompt မပို့မီ အေးဂျင့်နှင့် မော်ဒယ်ကို ရွေးပါ။",
-2
View File
@@ -376,8 +376,6 @@ export const dict: Record<string, string> = {
"prompt.attachment.remove": "संलग्नक हटाउनुहोस्",
"prompt.action.send": "पठाउनुहोस्",
"prompt.action.stop": "रोक्नुहोस्",
"prompt.toast.pasteUnsupported.title": "असमर्थित संलग्नक",
"prompt.toast.pasteUnsupported.description": "केवल छविहरू, PDF हरू, वा पाठ फाइलहरू यहाँ संलग्न गर्न सकिन्छ।",
"prompt.toast.attachmentDuplicate.title": "यो फाइल पहिले नै अपलोड गरिएको छ",
"prompt.toast.modelAgentRequired.title": "एक एजेन्ट र मोडेल चयन गर्नुहोस्",
"prompt.toast.modelAgentRequired.description": "प्रम्प्ट पठाउनु अघि एजेन्ट र मोडेल छान्नुहोस्।",
-3
View File
@@ -375,9 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Bijlage verwijderen",
"prompt.action.send": "Verzenden",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Niet-ondersteunde bijlage",
"prompt.toast.pasteUnsupported.description":
"Hier kunnen alleen afbeeldingen, pdf's of tekstbestanden worden bijgevoegd.",
"prompt.toast.attachmentDuplicate.title": "Dit bestand is al geüpload",
"prompt.toast.modelAgentRequired.title": "Selecteer een agent en model",
"prompt.toast.modelAgentRequired.description": "Kies een agent en model voordat je een prompt verzendt.",
-2
View File
@@ -393,9 +393,7 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
"prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp",
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke opprette worktree",
-3
View File
@@ -381,9 +381,6 @@ export const dict = {
"prompt.attachment.remove": "منسلکہ ہٹا دیو",
"prompt.action.send": "گھلو",
"prompt.action.stop": "روکو",
"prompt.toast.pasteUnsupported.title": "غیر تعاون یافتہ منسلکہ",
"prompt.toast.pasteUnsupported.description":
"ایتھے صرف تصویراں، پی ڈی ایف، یا ٹیکسٹ فائلاں منسلک کیتیاں جا سکدیاں نیں۔",
"prompt.toast.attachmentDuplicate.title": "ایہہ فائل پہلے ای اپ لوڈ ہو چکی اے",
"prompt.toast.modelAgentRequired.title": "اک ایجنٹ تے ماڈل چنو",
"prompt.toast.modelAgentRequired.description": "پرامپٹ بھیجن توں پہلاں اک ایجنٹ تے ماڈل دا انتخاب کرو۔",
-2
View File
@@ -382,9 +382,7 @@ export const dict = {
"prompt.attachment.remove": "Usuń załącznik",
"prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
"prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany",
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
"prompt.toast.worktreeCreateFailed.title": "Nie udało się utworzyć drzewa roboczego",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "Elimină atașamentul",
"prompt.action.send": "Trimite",
"prompt.action.stop": "Oprește",
"prompt.toast.pasteUnsupported.title": "Atașament neacceptat",
"prompt.toast.pasteUnsupported.description": "Poți atașa doar imagini, PDF-uri sau fișiere text aici.",
"prompt.toast.attachmentDuplicate.title": "Acest fișier a fost deja încărcat",
"prompt.toast.modelAgentRequired.title": "Selectează un agent și un model",
"prompt.toast.modelAgentRequired.description": "Alege un agent și un model înainte de a trimite un prompt.",
-2
View File
@@ -401,9 +401,7 @@ export const dict = {
"prompt.action.send": "Отправить",
"prompt.action.stop": "Остановить",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
"prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен",
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
"prompt.toast.worktreeCreateFailed.title": "Не удалось создать worktree",
-2
View File
@@ -374,8 +374,6 @@ export const dict: Record<string, string> = {
"prompt.attachment.remove": "ඇමුණුම ඉවත් කරන්න",
"prompt.action.send": "යවන්න",
"prompt.action.stop": "නවත්වන්න",
"prompt.toast.pasteUnsupported.title": "සහාය නොදක්වන ඇමුණුම",
"prompt.toast.pasteUnsupported.description": "පින්තූර, PDF හෝ පෙළ ගොනු පමණක් මෙහි ඇමිණිය හැක.",
"prompt.toast.attachmentDuplicate.title": "මෙම ගොනුව දැනටමත් උඩුගත කර ඇත",
"prompt.toast.modelAgentRequired.title": "නියෝජිතයෙකු සහ ආකෘතියක් තෝරන්න",
"prompt.toast.modelAgentRequired.description": "ප්‍රොම්ප්ට් එකක් යැවීමට පෙර නියෝජිතයෙකු සහ ආකෘතියක් තෝරන්න.",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "Odstrániť prílohu",
"prompt.action.send": "Odoslať",
"prompt.action.stop": "Zastaviť",
"prompt.toast.pasteUnsupported.title": "Nepodporovaná príloha",
"prompt.toast.pasteUnsupported.description": "Pripojiť možno len obrázky, PDF alebo textové súbory.",
"prompt.toast.attachmentDuplicate.title": "Tento súbor už bol nahraný",
"prompt.toast.modelAgentRequired.title": "Vyberte agenta a model",
"prompt.toast.modelAgentRequired.description": "Pred odoslaním výzvy vyberte agenta a model.",
-2
View File
@@ -374,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "Odstrani prilogo",
"prompt.action.send": "Pošlji",
"prompt.action.stop": "Ustavi",
"prompt.toast.pasteUnsupported.title": "Nepodprta priloga",
"prompt.toast.pasteUnsupported.description": "Sem lahko priložite samo slike, datoteke PDF ali besedilne datoteke.",
"prompt.toast.attachmentDuplicate.title": "Ta datoteka je že naložena",
"prompt.toast.modelAgentRequired.title": "Izberite agenta in model",
"prompt.toast.modelAgentRequired.description": "Preden pošljete poziv, izberite agenta in model.",
-3
View File
@@ -375,9 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Hiq shtojcën",
"prompt.action.send": "Dërgo",
"prompt.action.stop": "Ndalo",
"prompt.toast.pasteUnsupported.title": "Bashkëngjitje e pambështetur",
"prompt.toast.pasteUnsupported.description":
"Këtu mund të bashkëngjiten vetëm imazhe, skedarë PDF ose skedarë teksti.",
"prompt.toast.attachmentDuplicate.title": "Ky skedar tashmë është ngarkuar",
"prompt.toast.modelAgentRequired.title": "Zgjidhni një agjent dhe model",
"prompt.toast.modelAgentRequired.description": "Zgjidhni një agjent dhe model përpara se të dërgoni një kërkesë.",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Уклоните прилог",
"prompt.action.send": "Пошаљи",
"prompt.action.stop": "Стоп",
"prompt.toast.pasteUnsupported.title": "Неподржани прилог",
"prompt.toast.pasteUnsupported.description": "Овде се могу приложити само слике, PDFс или текстуалне датотеке.",
"prompt.toast.attachmentDuplicate.title": "Ова датотека је већ отпремљена",
"prompt.toast.modelAgentRequired.title": "Изаберите агента и модел",
"prompt.toast.modelAgentRequired.description": "Одаберите агента и модел пре него што пошаљете упит.",
-2
View File
@@ -376,8 +376,6 @@ export const dict = {
"prompt.attachment.remove": "Ta bort bilagan",
"prompt.action.send": "Skicka",
"prompt.action.stop": "Stoppa",
"prompt.toast.pasteUnsupported.title": "Bilaga som inte stöds",
"prompt.toast.pasteUnsupported.description": "Endast bilder, PDF-filer eller textfiler kan bifogas här.",
"prompt.toast.attachmentDuplicate.title": "Den här filen har redan laddats upp",
"prompt.toast.modelAgentRequired.title": "Välj en agent och modell",
"prompt.toast.modelAgentRequired.description": "Välj en agent och modell innan du skickar en prompt.",
-3
View File
@@ -376,9 +376,6 @@ export const dict = {
"prompt.attachment.remove": "Замимаро хориҷ кунед",
"prompt.action.send": "Фиристодан",
"prompt.action.stop": "Ист",
"prompt.toast.pasteUnsupported.title": "Замимаи дастгирӣнашаванда",
"prompt.toast.pasteUnsupported.description":
"Дар ин ҷо танҳо тасвирҳо, PDFс ё файлҳои матнӣ замима кардан мумкин аст.",
"prompt.toast.attachmentDuplicate.title": "Ин файл аллакай бор карда шудааст",
"prompt.toast.modelAgentRequired.title": "Агент ва моделро интихоб кунед",
"prompt.toast.modelAgentRequired.description": "Пеш аз фиристодани промпт агент ва моделро интихоб кунед.",
-2
View File
@@ -400,9 +400,7 @@ export const dict = {
"prompt.action.send": "ส่ง",
"prompt.action.stop": "หยุด",
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
"prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว",
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ PDF หรือไฟล์ข้อความเท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพรอมต์",
"prompt.toast.worktreeCreateFailed.title": "ไม่สามารถสร้าง worktree",
-2
View File
@@ -375,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Goşundyny aýyryň",
"prompt.action.send": "Iber",
"prompt.action.stop": "Dur",
"prompt.toast.pasteUnsupported.title": "Goldaw berilmeýän goşundy",
"prompt.toast.pasteUnsupported.description": "Bu ýerde diňe suratlar, PDF ýa-da tekst faýllary birikdirilip bilner.",
"prompt.toast.attachmentDuplicate.title": "Bu faýl eýýäm ýüklendi",
"prompt.toast.modelAgentRequired.title": "Agent we model saýlaň",
"prompt.toast.modelAgentRequired.description": "Sorag ibermezden ozal agent we model saýlaň.",
-2
View File
@@ -407,9 +407,7 @@ export const dict = {
"prompt.action.send": "Gönder",
"prompt.action.stop": "Durdur",
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
"prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi",
"prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
"prompt.toast.modelAgentRequired.description": "İstem göndermeden önce bir ajan ve model seçin.",
"prompt.toast.worktreeCreateFailed.title": "Çalışma ağacı oluşturulamadı",
-2
View File
@@ -404,9 +404,7 @@ export const dict = {
"prompt.action.send": "Надіслати",
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
"prompt.toast.worktreeCreateFailed.title": "Не вдалося створити робоче дерево",
-2
View File
@@ -384,8 +384,6 @@ export const dict = {
"prompt.attachment.remove": "منسلکہ کو ہٹا دیں۔",
"prompt.action.send": "بھیجیں۔",
"prompt.action.stop": "روکیں",
"prompt.toast.pasteUnsupported.title": "غیر تعاون یافتہ منسلکہ",
"prompt.toast.pasteUnsupported.description": "یہاں صرف تصاویر، PDFs، یا ٹیکسٹ فائلیں منسلک کی جا سکتی ہیں۔",
"prompt.toast.attachmentDuplicate.title": "یہ فائل پہلے ہی اپ لوڈ ہو چکی ہے",
"prompt.toast.modelAgentRequired.title": "ایک ایجنٹ اور ماڈل منتخب کریں۔",
"prompt.toast.modelAgentRequired.description": "پرامپٹ بھیجنے سے پہلے ایک ایجنٹ اور ماڈل کا انتخاب کریں۔",
-2
View File
@@ -377,8 +377,6 @@ export const dict = {
"prompt.attachment.remove": "Qo'shimchani olib tashlang",
"prompt.action.send": "Yuborish",
"prompt.action.stop": "To'xtang",
"prompt.toast.pasteUnsupported.title": "Qoʻllab-quvvatlanmaydigan biriktirma",
"prompt.toast.pasteUnsupported.description": "Bu yerda faqat rasmlar, PDF yoki matnli fayllar biriktirilishi mumkin.",
"prompt.toast.attachmentDuplicate.title": "Bu fayl allaqachon yuklangan",
"prompt.toast.modelAgentRequired.title": "Agent va modelni tanlang",
"prompt.toast.modelAgentRequired.description": "So'rov yuborishdan oldin agent va modelni tanlang.",
-2
View File
@@ -382,8 +382,6 @@ export const dict = {
"prompt.attachment.remove": "Xóa tệp đính kèm",
"prompt.action.send": "Gửi",
"prompt.action.stop": "Dừng",
"prompt.toast.pasteUnsupported.title": "Tệp đính kèm không được hỗ trợ",
"prompt.toast.pasteUnsupported.description": "Chỉ có thể đính kèm hình ảnh, tệp PDF hoặc tệp văn bản ở đây.",
"prompt.toast.attachmentDuplicate.title": "Tệp này đã được tải lên",
"prompt.toast.modelAgentRequired.title": "Chọn tác nhân và mô hình",
"prompt.toast.modelAgentRequired.description": "Chọn một tác nhân và mô hình trước khi gửi lời nhắc.",
-2
View File
@@ -419,9 +419,7 @@ export const dict = {
"prompt.attachment.remove": "移除附件",
"prompt.action.send": "发送",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支持的附件",
"prompt.toast.attachmentDuplicate.title": "此文件已上传",
"prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
"prompt.toast.worktreeCreateFailed.title": "创建工作区失败",
-2
View File
@@ -400,9 +400,7 @@ export const dict = {
"prompt.action.send": "傳送",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支援的附件",
"prompt.toast.attachmentDuplicate.title": "此檔案已上傳",
"prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
"prompt.toast.worktreeCreateFailed.title": "建立工作樹失敗",
+10 -5
View File
@@ -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"]
+29 -15
View File
@@ -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, {
+1 -1
View File
@@ -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
+4 -9
View File
@@ -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 },
],
},
},
+6 -1
View File
@@ -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,
@@ -2,6 +2,7 @@ import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useAttachmentDestination } from "@/composer/attachments/deliver"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
@@ -29,6 +30,7 @@ export function createSessionComposerController(input: {
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
destination: useAttachmentDestination(input.controls),
restoreFocus: (cursor) => {
const target = editor
if (!target) return
+9 -6
View File
@@ -8,7 +8,8 @@ import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -29,6 +30,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
destination: () => AttachmentDestination
restoreFocus: (cursor: number) => void
}) {
const data = useData()
@@ -59,6 +61,7 @@ export function createSessionQueue(input: {
change.item,
change.prompt,
change.text,
input.destination(),
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
@@ -289,13 +292,13 @@ async function editedPromptInput(
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
destination: AttachmentDestination,
) {
const images = await Promise.all(
prompt
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
const attachments = await deliverAttachments(
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
destination,
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
+14 -6
View File
@@ -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>
),
+8 -6
View File
@@ -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 })),
]
}
+72
View File
@@ -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%;

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