Compare commits

..
Author SHA1 Message Date
Aiden Cline b53b3d3635 fix(ai): never fail decoding on Responses error frames 2026-09-16 14:19:23 -05:00
215 changed files with 1168 additions and 2787 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/info` with operation ID `server.info`.
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **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/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `GET` | `/api/status` | `server.status` | 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. |
@@ -73,11 +73,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
@@ -108,7 +108,7 @@ const incremental = (
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
const rejected = (
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
+28 -94
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -333,53 +333,13 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
// https://www.openresponses.org/specification
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
const asText = (value: unknown) =>
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
export const Event = Schema.StructWithRest(
Schema.Struct({
@@ -400,31 +360,18 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
error: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
code: Schema.optional(Schema.Unknown),
message: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -433,16 +380,15 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
* failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
? { ...value, type: "error" }
: value,
),
@@ -1422,22 +1368,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...current, lifecycle }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
export const errorDetail = (event: Event) => {
const raw = event.error ?? event.response?.error
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
return {
message: asText(event.message) ?? asText(nested?.message),
code: asText(event.code) ?? asText(nested?.code),
}
}
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
// visible; fall back to the raw frame rather than a generic message when neither decodes.
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
const nested = event.error ?? event.response?.error ?? undefined
const summary = providerErrorMessage(event, nested)
const detail = errorDetail(event)
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
@@ -1520,18 +1465,7 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
state.id,
`${state.name} returned a malformed error event`,
ProviderShared.encodeJson(event),
cause,
),
),
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
)
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+6 -21
View File
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// 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 QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -95,8 +87,7 @@ 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
// 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
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/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
@@ -152,11 +143,7 @@ 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 (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
if (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)
@@ -176,12 +163,10 @@ 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("does not let server codes override a 4xx rejection", () =>
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
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: "InvalidRequest" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
yield* classify('{"code":"service_unavailable"}')
}),
)
+3 -47
View File
@@ -249,54 +249,10 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[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,
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).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")
).toEqual(Array(6).fill("InvalidRequest"))
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -11,66 +11,78 @@ import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
for (const frame of [
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
{ type: "error" },
{ type: "error", error: "Gateway failed" },
{ type: "error", error: { code: 429, message: "slow down" } },
{ type: "error", error: 42 },
{ type: "error", code: 500, message: ["not", "a", "string"] },
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
it.effect("reads bare WebSocket error envelopes as error events", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
for (const error of [{ type: "api_error", message }, message, 42]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
}
for (const frame of [{ error: null }, { message }]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
}
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
[
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
"server_shutting_down: Shutting down",
"UnknownProvider",
],
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
[
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
"Gateway failed",
"UnknownProvider",
],
]
for (const [frame, message, tag] of cases) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(message)
expect(error.reason._tag).toBe(tag)
expect(error.reason.body).toBe(JSON.stringify(frame))
}
for (const frame of [
{ type: "error", error: 42 },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
]) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(JSON.stringify(frame))
expect(error.reason._tag).toBe("UnknownProvider")
}
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
}),
)
@@ -62,8 +62,7 @@ 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/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
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,13 +16,8 @@ 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/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
}
return json(route, {})
})
@@ -138,14 +138,7 @@ 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"],
paths: { tmp: "/tmp/opencode" },
},
}),
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
)
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/info")
const response = await fetch("/api/status")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -172,6 +172,64 @@ 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/info")
if (path === "/api/status")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
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,13 +334,8 @@ 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/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
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("info", "/api/info", { success: Json }))
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
+1 -7
View File
@@ -219,13 +219,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
@@ -4,6 +4,64 @@ 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
@@ -17,6 +75,7 @@ export type ComposerAttachmentConfig = {
) => Promise<void>
directory: () => string
isDialogActive: () => boolean
warn: () => void
duplicate: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
@@ -43,9 +102,13 @@ export function createComposerAttachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, target = capture(), clipboard = false) => {
const add = async (file: File, toast = true, 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
@@ -75,11 +138,13 @@ export function createComposerAttachments(
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachments = async (files: File[], target = capture()) => {
return files.reduce(async (result, file) => {
const addAttachments = async (files: File[], toast = true, target = capture()) => {
const found = await files.reduce(async (result, file) => {
const previous = await result
return (await add(file, target)) || previous
return (await add(file, false, target)) || previous
}, Promise.resolve(false))
if (!found && files.length > 0 && toast) input.warn()
return found
}
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
@@ -94,13 +159,13 @@ export function createComposerAttachments(
return file ? [file] : []
})
if (files.length > 0) {
await addAttachments(files, target)
await addAttachments(files, true, target)
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file && (await add(file, target, true))) return
if (file && (await add(file, true, target, true))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
@@ -158,7 +223,9 @@ export function createComposerAttachments(
fallback()
return
}
void input.picker({ defaultPath: input.directory(), multiple: true }, (file) => add(file)).catch(input.onError)
void input
.picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
.catch(input.onError)
},
}
}
@@ -182,8 +249,6 @@ 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
@@ -194,11 +259,10 @@ 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 binary
if (bytes.some((byte) => byte === 0)) return
const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
if (bytes.length > 0 && control / bytes.length > 0.3) return binary
if (bytes.length > 0 && control / bytes.length > 0.3) return
return "text/plain"
}
@@ -1,70 +0,0 @@
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,
attachments: [],
images: [],
text: value,
sessionDirectory: "C:/repo",
})
@@ -102,6 +102,7 @@ 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
@@ -1,19 +0,0 @@
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
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
if (
attachments &&
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
) {
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 normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
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(normalized)
const node = document.createTextNode(text)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
@@ -450,12 +450,6 @@ 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
+5 -2
View File
@@ -22,7 +22,6 @@ 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"]
@@ -266,7 +265,6 @@ 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"
@@ -341,6 +339,11 @@ 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({
+34 -27
View File
@@ -1,17 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Skill } from "@opencode/schema/skill"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import type { DeliveredAttachment } from "./attachments/deliver"
import type { Prompt } from "@/composer/state"
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 = [
@@ -30,7 +21,9 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
attachments: [inline("a.png", "image/png")],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -52,7 +45,16 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
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",
},
],
text: "check these",
sessionDirectory: "/repo",
})
@@ -67,10 +69,15 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
attachments: [
inline("opencode.global.dat", "text/plain", {
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
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",
@@ -95,7 +102,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
attachments: [],
images: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
@@ -117,7 +124,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
attachments: [],
images: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
@@ -139,7 +146,7 @@ describe("buildPromptRequest", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
attachments: [],
images: [],
text: "look",
sessionDirectory: "/repo",
})
@@ -155,7 +162,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
@@ -176,7 +183,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
@@ -197,7 +204,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
@@ -211,7 +218,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
@@ -226,7 +233,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" },
],
attachments: [],
images: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
@@ -248,7 +255,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
@@ -275,7 +282,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
@@ -300,7 +307,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
@@ -330,7 +337,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
attachments: [],
images: [],
text: "@review",
sessionDirectory: "/repo",
})
+10 -16
View File
@@ -1,9 +1,8 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, 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 = {
@@ -29,7 +28,7 @@ type ContextFile = {
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
attachments: DeliveredAttachment[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
}
@@ -107,21 +106,16 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
return [file, ...mentions]
})
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")
const images = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
return {
text: [...(body ? [body] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: body,
files: [...files, ...context, ...inline],
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
agents,
skills,
comments,
-10
View File
@@ -3,7 +3,6 @@ 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"
@@ -49,14 +48,6 @@ 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) {} },
@@ -73,7 +64,6 @@ function submitInput(
resetHistory() {},
setMode() {},
closePopover() {},
destination: () => destination,
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
+20 -30
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 { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -34,7 +34,6 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
destination: () => AttachmentDestination
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
@@ -87,16 +86,10 @@ 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.destination(),
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.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 }),
)
@@ -129,13 +122,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(
session,
value,
command,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -304,10 +293,9 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
destination: AttachmentDestination,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value, destination)
const request = await buildSubmissionRequest(session, value)
// 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({
@@ -346,11 +334,10 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value, destination)
const request = await buildSubmissionRequest(session, value)
// 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
@@ -383,18 +370,21 @@ async function sendPrompt(
await sending
}
async function buildSubmissionRequest(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
) {
return buildPromptRequest({
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({
prompt: value.prompt,
context: value.context,
attachments: await deliverAttachments(value.images, destination),
images,
text: value.text,
sessionDirectory: session.directory,
})
return request
}
function failSubmission(
+1 -1
View File
@@ -1,4 +1,5 @@
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"
@@ -14,7 +15,6 @@ 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,6 +371,8 @@ 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,7 +380,9 @@ 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,6 +378,8 @@ 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,6 +378,8 @@ 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,6 +375,8 @@ 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,7 +382,9 @@ 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,7 +403,9 @@ 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,6 +377,8 @@ 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,6 +375,8 @@ 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,7 +300,9 @@ 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,7 +286,9 @@ 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,6 +380,9 @@ 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,6 +379,9 @@ 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,6 +376,8 @@ 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,6 +365,8 @@ 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,7 +403,10 @@ 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,6 +374,8 @@ 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,6 +375,8 @@ 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,6 +282,8 @@ 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,6 +374,8 @@ 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,7 +385,10 @@ 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,6 +373,8 @@ 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,6 +382,8 @@ 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,6 +379,8 @@ 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,6 +379,8 @@ 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,6 +377,8 @@ 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,6 +403,8 @@ 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,6 +379,8 @@ 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,6 +284,8 @@ 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,7 +379,9 @@ 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,6 +375,8 @@ 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,6 +374,8 @@ 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,7 +268,9 @@ 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,6 +374,8 @@ 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,6 +380,8 @@ 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,6 +375,8 @@ 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,6 +376,8 @@ 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,6 +378,8 @@ 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,6 +375,8 @@ 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,6 +378,9 @@ 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,6 +376,8 @@ 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,6 +375,9 @@ 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,7 +393,9 @@ 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,6 +381,9 @@ 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,7 +382,9 @@ 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,6 +374,8 @@ 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,7 +401,9 @@ 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,6 +374,8 @@ 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,6 +374,8 @@ 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,6 +374,8 @@ 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,6 +375,9 @@ 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,6 +375,8 @@ 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,6 +376,8 @@ 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,6 +376,9 @@ 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,7 +400,9 @@ 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,6 +375,8 @@ 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,7 +407,9 @@ 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,7 +404,9 @@ 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,6 +384,8 @@ 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,6 +377,8 @@ 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,6 +382,8 @@ 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,7 +419,9 @@ 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,7 +400,9 @@ 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": "建立工作樹失敗",
+5 -10
View File
@@ -90,12 +90,7 @@ 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],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -103,23 +98,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.server.info()
await transport.api.server.status()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.server.info()
await transport.api.server.status()
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/info",
url: "http://127.0.0.1:4100/api/status",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/info",
url: "http://127.0.0.1:4200/api/status",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
@@ -5,7 +5,6 @@ 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"
@@ -65,44 +64,6 @@ 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"]
+15 -29
View File
@@ -17,12 +17,7 @@ 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],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
}) as typeof globalThis.fetch
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
@@ -33,19 +28,16 @@ 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], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
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/info")
expect(request?.pathname).toBe("/api/status")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -60,7 +52,7 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
@@ -119,13 +111,10 @@ 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], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
@@ -141,13 +130,10 @@ 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], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
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.info({ signal })
.server.status({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
+9 -4
View File
@@ -8,8 +8,11 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// Retain closed paths until reopened so settings can exclude them from the server inventory.
// The Home page independently limits the visible recently closed entries.
// 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
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
@@ -48,7 +51,6 @@ export function createServerProjects(input: {
}
return {
list: current,
closed: currentClosed,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
@@ -70,7 +72,10 @@ export function createServerProjects(input: {
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
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/info").catch(() => undefined)
input.queue.fetch("http://server/api/status").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/info", ms: 2_000 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
],
},
},
+1 -6
View File
@@ -146,11 +146,7 @@ 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,
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
...project,
}
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
@@ -178,7 +174,6 @@ function createServerController(
projects: {
...projects,
list: projectsList,
resolve: enrich,
recentlyClosed: recentlyClosedList,
},
notification,
@@ -2,7 +2,6 @@ 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"
@@ -30,7 +29,6 @@ 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
+6 -9
View File
@@ -8,8 +8,7 @@ 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 { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -30,7 +29,6 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
destination: () => AttachmentDestination
restoreFocus: (cursor: number) => void
}) {
const data = useData()
@@ -61,7 +59,6 @@ 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({
@@ -292,13 +289,13 @@ async function editedPromptInput(
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
destination: AttachmentDestination,
) {
const attachments = await deliverAttachments(
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
destination,
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 request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
const request = buildPromptRequest({ prompt, context: [], images, 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) : ""

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