Compare commits

..

31 Commits

Author SHA1 Message Date
Kit Langton aa6297f7ae fix(tui): preserve multi-click text selection 2026-08-26 10:57:09 -04:00
Dax Raad cbef698861 fix(core): suppress state updates during location teardown 2026-08-26 10:48:44 -04:00
James Long ab2d251155 fix(server): unblock persistent terminal input in Bun builds (#45287) 2026-08-26 10:47:16 -04:00
Kit Langton 3d7ba38965 feat(tui): investigate errors in a new session 2026-08-26 10:43:40 -04:00
Aiden Cline 37a6ba893e chore(ai): format package with prettier (#45280) 2026-08-26 09:24:13 -05:00
Brendan Allan 6c6871fd2a fix(app): include files in mention search (#45281) 2026-08-26 22:18:34 +08:00
opencode-agent[bot] 1e864dd8c6 chore: update nix node_modules hashes 2026-08-26 13:49:52 +00:00
James Long 21980a4448 fix(tui): restore terminal settings and move pane toggle to sessions (#45271) 2026-08-26 09:46:33 -04:00
James Long 9cca8dd6e0 feat(tui): add persistent session terminals (#44971) 2026-08-26 09:30:03 -04:00
Dax 91028a690b feat(tui): show LLM token throughput 2026-08-26 09:23:53 -04:00
Shoubhit Dash e82aa92e64 fix(session): support assistant message content updates (#45015) 2026-08-26 18:31:59 +05:30
opencode-agent[bot] 874538d702 fix(desktop): restrict macOS app entitlements (#45258)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 12:53:00 +00:00
opencode-agent[bot] afc26c72c0 chore: update nix node_modules hashes 2026-08-26 12:11:06 +00:00
James Long d572a5756c feat(cli): embed persistent PTY binaries from npm packages (#45248) 2026-08-26 07:55:30 -04:00
opencode-agent[bot] ab95695b24 fix(app): combine loaded skill and instruction file entries (#45217)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 10:08:33 +00:00
Luke Parker 5d7c2ccfc0 feat(app): add experimental vertical session tabs (#45210) 2026-08-26 19:40:09 +10:00
Luke Parker 24ac05868c fix(desktop): align and theme Windows caption controls (#45208) 2026-08-26 09:02:10 +00:00
Shoubhit Dash 4eaf533cd0 feat(plugin): add declarative vcs repository markers (#45192) 2026-08-26 13:49:36 +05:30
Shoubhit Dash 0f7a76eff0 fix(ai): keep bedrock mantle responses on http (#45197) 2026-08-26 13:38:27 +05:30
Luke Parker 16d731bd67 feat(app): render settings as a fullscreen surface (#45190) 2026-08-26 07:57:36 +00:00
Luke Parker ea582fc133 feat(app): stack collapsed tool calls (#45176) 2026-08-26 07:56:53 +00:00
opencode-agent[bot] 7e27e81bc7 fix(app): preserve timeline measurements when moving sessions (#45145)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 14:39:01 +08:00
opencode-agent[bot] 667722897e fix(app): identify session worktrees from project inventory (#45153)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 14:38:08 +08:00
opencode-agent[bot] d12dbd12a9 fix(app): prevent duplicate rows when editing queued prompts (#45169)l
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 06:21:44 +00:00
opencode-agent[bot] b0bd0bc394 fix(app): align project settings server sections (#45163)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 04:42:26 +00:00
Dax 437df1164c feat(plugin): expose session move 2026-08-26 00:14:12 -04:00
opencode-agent[bot] 9a91e21a76 fix(app): preserve workspace choices across draft tabs (#45149)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 03:51:14 +00:00
opencode-agent[bot] d01ac2069a chore: update nix node_modules hashes 2026-08-26 03:32:00 +00:00
Aiden Cline a78d3c5438 fix(ai): recover Anthropic request_too_large as overflow (#45144) 2026-08-25 22:30:14 -05:00
David Hill 474c3588c1 fix(tui): use unicode ellipses in interface text (#45143) 2026-08-25 22:26:15 -05:00
Kit Langton 9361117504 feat(core): add directory projects (#45107) 2026-08-25 23:22:23 -04:00
202 changed files with 5824 additions and 1175 deletions
+16
View File
@@ -125,6 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,6 +365,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -2155,6 +2157,20 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.9", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.9", "@opencode-ai/pty-darwin-x64": "0.1.9", "@opencode-ai/pty-linux-arm64-gnu": "0.1.9", "@opencode-ai/pty-linux-arm64-musl": "0.1.9", "@opencode-ai/pty-linux-x64-gnu": "0.1.9", "@opencode-ai/pty-linux-x64-musl": "0.1.9" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-9WysQgX9J3RXfZy/t/8MGqf1IGLeckyHQsT/eVnKiBnp+GnzOXtZePryj08CN/3GT2BuP5tWqMRMH0JzCMsrgg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-a2OZGutBdVGDO+X4t37L2K8wD1phTcLJBcNLj8j3LqCmaHbJmQyNYYFo6i8loZCTjjhb+8Wq0wMxSLny4tqdwA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-+vyRLwzNMzP/JFtYEIkMHxRC9Lkd7sUeuAqI5medtdgZmvTwS3NdkHbq6zQQG3rTQ9asObPKilvcpQDf+LYHRQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vjNKhCsw6mI6w+9bITCyCmb5nS+XiWA8xMGn02GOKE9AahcVX9JIbjUF1zAmPXZ1QD7nxWoanh4FvnqukbYPHw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-xpS0N6/uEiJPabv6Ib9BpOlfyZdcUES7sMVa4bCrgcxy6y4bnQMeGF/Ju4u7LBABD/rjBKZM0XhPMnvZ6qoX1w=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-N1Dx8rOLkpJd2DSetZZW9dPnpL3mxbRQagk/7K7TbS7M1rDnLazhq8/T9vulLET8KTwu6lmWcZYuSDfPLNWXnA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-osb203LrlwXpQKQABrCfD5QRVK9Ajiq5atulC/VuMye6KkErcetDnjQvobjhaEjgnSEGPCH4U9sM84okGqip5Q=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
}
}
+6 -1
View File
@@ -36,7 +36,12 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"google-vertex-messages",
"bedrock-converse",
"openrouter",
])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
+35 -23
View File
@@ -69,14 +69,22 @@ export interface OptionsInput {
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ProviderOptionsInput = OptionsInput
@@ -259,7 +267,11 @@ const AnthropicToolChoice = Schema.Union([
type: Schema.Literals(["auto", "any", "none"]),
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
Schema.Struct({
type: Schema.tag("tool"),
name: Schema.String,
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
])
const AnthropicThinking = Schema.Union([
@@ -506,7 +518,11 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
content: payload,
} satisfies AnthropicServerToolResultBlock
})
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
@@ -554,9 +570,7 @@ const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string |
return undefined
}
const citationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicDocumentBlock["citations"] | undefined => {
const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocumentBlock["citations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const raw = ProviderShared.isRecord(metadata.anthropic)
? (metadata.anthropic.citations ?? metadata.citations)
@@ -706,8 +720,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
if (request.model.compatibility?.requireSignature !== undefined) return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
@@ -900,21 +913,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) &&
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
@@ -965,8 +981,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
const budget =
@@ -1418,9 +1433,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`))
}
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
}
@@ -1473,10 +1486,9 @@ export const route = Route.make({
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
{ baseURL: DEFAULT_BASE_URL },
),
endpoint: Endpoint.path((input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH), {
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
@@ -652,7 +652,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
method: "stream",
reason: classifyProviderFailure({
message:
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
event.exception.details.message ??
event.exception.details.originalMessage ??
"Bedrock Converse stream error",
code: event.exception.type,
}),
})
@@ -82,7 +82,9 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
out.push(
messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed },
)
}
return [cursor, out] as const
})
+9 -3
View File
@@ -570,7 +570,12 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
"text-0",
googleMetadata({ thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -675,8 +680,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
hasToolCalls = true
+31 -25
View File
@@ -496,7 +496,11 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (mistral)
return id
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, 9)
.padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
@@ -505,7 +509,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
@@ -557,7 +562,10 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
if (
message.role === "assistant" &&
message.content.every((part) => part.type === "text" && part.text.trim() === "")
)
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, lowering)
@@ -588,7 +596,10 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const detectMaxTokensField = (
provider: string,
baseURL: string | undefined,
): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
@@ -638,7 +649,8 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isVercelAiGateway =
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
@@ -670,11 +682,7 @@ const detectSupportsStrictMode = (provider: string, baseURL: string | undefined)
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const detectZaiToolStream = (provider: string, baseURL: string | undefined, modelID: string): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
@@ -724,10 +732,10 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const supportsStrictMode =
request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
@@ -816,11 +824,10 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached =
(usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cached = (usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
@@ -936,13 +943,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason
? {
normalized: yield* mapFinishReason(event, rawFinishReason),
raw: choice?.native_finish_reason ?? rawFinishReason,
}
: state.finishReason
const finishReason = rawFinishReason
? {
normalized: yield* mapFinishReason(event, rawFinishReason),
raw: choice?.native_finish_reason ?? rawFinishReason,
}
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
@@ -29,10 +29,9 @@ export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
export const ServiceTier = Schema.declare<ServiceTier>(
(value): value is ServiceTier => typeof value === "string",
{ title: "ServiceTier" },
)
export const ServiceTier = Schema.declare<ServiceTier>((value): value is ServiceTier => typeof value === "string", {
title: "ServiceTier",
})
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
@@ -34,37 +34,35 @@ export const onDone: (
state: OpenResponses.ParserState,
item: Item,
tools: Definitions,
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
state,
item,
tools,
) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: tool.result
? yield* tool.result(item)
: item.error !== undefined && item.error !== null
? { type: "error", value: item.error }
: { type: "json", value: item },
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
function* (state, item, tools) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: tool.result
? yield* tool.result(item)
: item.error !== undefined && item.error !== null
? { type: "error", value: item.error }
: { type: "json", value: item },
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
},
)
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
+4 -2
View File
@@ -40,15 +40,16 @@ const patterns = [
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
@@ -106,6 +107,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
clientScoped &&
(codes.includes("context_length_exceeded") ||
codes.includes("model_context_window_exceeded") ||
codes.includes("request_too_large") ||
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
@@ -1,5 +1,5 @@
import { Auth } from "../route/auth.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
@@ -26,14 +26,15 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = OpenAIResponses.route.with({
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
transport: OpenAIResponses.channelTransport({
id: "bedrock-mantle-responses",
name: "Bedrock Mantle Responses",
enabled: () => false,
}),
providerMetadataKey: OpenAIResponses.route.providerMetadataKey,
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
})
const chatRoute = OpenAIChat.route.with({
@@ -43,7 +44,7 @@ const chatRoute = OpenAIChat.route.with({
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
+1 -3
View File
@@ -339,9 +339,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return onHalt
? parsed.pipe(
Stream.concat(
Stream.suspend(() =>
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
),
Stream.suspend(() => Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable)))),
),
)
: parsed
+22
View File
@@ -211,6 +211,28 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "context-overflow",
http: { response: { status: 413 } },
})
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
),
),
)
it.effect("does not classify ordinary invalid requests as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/chat-streams-text",
"recordedAt": "2026-08-23T17:21:53.198Z"
},
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/responses-calls-a-tool",
"recordedAt": "2026-08-23T17:21:55.170Z"
},
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/responses-continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:56.397Z"
},
@@ -1,10 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"tags": ["prefix:azure", "provider:azure"],
"name": "azure/responses-streams-text",
"recordedAt": "2026-08-23T17:21:54.158Z"
},
@@ -2,11 +2,7 @@
"version": 1,
"metadata": {
"model": "openai.gpt-oss-120b",
"tags": [
"prefix:bedrock-mantle",
"provider:amazon-bedrock",
"protocol:openai-responses"
],
"tags": ["prefix:bedrock-mantle", "provider:amazon-bedrock", "protocol:openai-responses"],
"name": "bedrock-mantle/streams-text",
"recordedAt": "2026-08-25T03:29:02.968Z"
},
@@ -7,13 +7,7 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
},
"interactions": [
{
@@ -35,4 +29,4 @@
}
}
]
}
}
@@ -1,11 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/calls-a-tool",
"recordedAt": "2026-08-23T17:21:51.036Z"
},
@@ -1,11 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:51.853Z"
},
@@ -1,11 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/streams-text",
"recordedAt": "2026-08-23T17:21:50.112Z"
},
@@ -1,12 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:openai-responses-cache",
"provider:openai",
"protocol:openai-responses",
"cache"
],
"tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"],
"name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call",
"recordedAt": "2026-08-25T03:29:25.124Z"
},
@@ -5,14 +5,7 @@
"route": "openai-responses",
"transport": "http",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses",
"provider:openai",
"flagship",
"tool",
"tool-loop",
"golden"
],
"tags": ["prefix:openai-responses", "provider:openai", "flagship", "tool", "tool-loop", "golden"],
"name": "openai-responses/openai-responses-gpt-5-5-tool-loop",
"recordedAt": "2026-08-20T06:30:22.262Z"
},
@@ -1,14 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
"name": "pdf/openai-tool-result",
"recordedAt": "2026-08-25T03:29:08.297Z"
},
@@ -1,13 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"user-input"
],
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
"name": "pdf/openai-user-input",
"recordedAt": "2026-08-25T03:29:05.645Z"
},
@@ -1,14 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:xai-responses",
"tool",
"tool-result"
],
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "tool", "tool-result"],
"name": "pdf/xai-tool-result",
"recordedAt": "2026-08-25T03:29:11.774Z"
},
@@ -1,13 +1,7 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:xai-responses",
"user-input"
],
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "user-input"],
"name": "pdf/xai-user-input",
"recordedAt": "2026-08-25T03:29:10.612Z"
},
@@ -52,4 +52,4 @@
}
}
]
}
}
@@ -2,12 +2,7 @@
"version": 1,
"metadata": {
"model": "anthropic/claude-sonnet-4.6",
"tags": [
"prefix:openai-compatible-chat",
"provider:vercel-ai-gateway",
"protocol:openai-chat",
"reasoning"
],
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
"name": "vercel-ai-gateway-reasoning",
"recordedAt": "2026-07-18T11:28:42.077Z"
},
@@ -31,4 +26,4 @@
}
}
]
}
}
+10 -7
View File
@@ -18,13 +18,19 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
test("classifies Anthropic request_too_large as recoverable overflow", () => {
expect(
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
expect(isContextOverflow("413 status code (no body)")).toBe(true)
})
test("classifies generic request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
@@ -33,7 +39,6 @@ describe("provider error classification", () => {
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
@@ -84,9 +89,7 @@ describe("provider error classification", () => {
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
@@ -19,6 +20,7 @@ describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -59,27 +61,6 @@ describe("Amazon Bedrock Mantle provider", () => {
}),
)
it.effect("keeps Responses on HTTP when a WebSocket executor is available", () =>
Effect.gen(function* () {
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" }), {
webSocket: { execute: () => Effect.die("unexpected Mantle WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) => {
expect(input.request.url).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/responses")
return Effect.succeed(
input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
}),
)
}),
),
)
}),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
+9 -9
View File
@@ -515,7 +515,10 @@ describe("Gemini route", () => {
{
role: "model",
parts: [
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
{
functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } },
thoughtSignature: "sig_1",
},
],
},
{
@@ -606,10 +609,7 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
parts: [{ functionCall: { name: "shot", args: {} } }, { functionCall: { name: "shot", args: {} } }],
},
{
role: "user",
@@ -1071,7 +1071,9 @@ describe("Gemini route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
messages: [
Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }]),
],
}),
)
expect(prepared.body.contents).toEqual([
@@ -1572,9 +1574,7 @@ describe("Gemini route", () => {
{ candidates: [{ content: { role: "model", parts: null } }] },
{ candidates: [{ content: null, finishReason: null }] },
{
candidates: [
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
],
candidates: [{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const }],
},
),
),
@@ -26,9 +26,7 @@ const recorded = recordedTests({
describe("Google Vertex Gemini recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Reply with exactly one word: hello" }))
expect(response.text.toLowerCase()).toContain("hello")
}),
+3 -1
View File
@@ -80,7 +80,9 @@ describe("Z.ai Images", () => {
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image(
"model",
),
prompt: "A red circle \uD800 on a white background \u{1F600}",
}).pipe(
Effect.provide(
@@ -24,11 +24,17 @@ test("session settings use the remote server context", async ({ page }) => {
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
const sessionHeading = page.getByRole("heading", { name: sessionB.title, exact: true, includeHidden: true })
await expect(sessionHeading).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
@@ -55,9 +61,12 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(sessionHeading).toBeVisible()
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
@@ -78,7 +87,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -178,7 +187,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -234,7 +243,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -2,7 +2,7 @@ import { expect, test, type Route } from "@playwright/test"
const server = "http://127.0.0.1:4097"
test("nested server dialog keeps focus inside the top layer", async ({ page }) => {
test("server dialog keeps focus above fullscreen settings", async ({ page }) => {
await page.addInitScript((server) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] }))
}, server)
@@ -24,8 +24,9 @@ test("nested server dialog keeps focus inside the top layer", async ({ page }) =
await page.goto("/")
await page.keyboard.press("Control+,")
const settings = page.locator(".settings-dialog")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
@@ -41,6 +42,9 @@ test("nested server dialog keeps focus inside the top layer", async ({ page }) =
await expect(password).toBeFocused()
await password.fill("secret")
await expect(password).toHaveValue("secret")
await page.keyboard.press("Escape")
await expect(editor).toBeHidden()
await expect(settings).toBeVisible()
})
function json(route: Route, body: unknown, status = 200) {
@@ -9,7 +9,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
const trigger = page.getByRole("button", { name: "Used Shell" })
await trigger.focus()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
@@ -40,7 +40,7 @@ test.describe("regression: session timeline context group resize", () => {
expect(samples.at(-1)?.expanded).toBe("true")
})
test("paints a stable exploring to explored transition", async ({ page }) => {
test("keeps a grouped tool summary stable as its calls complete", async ({ page }) => {
const events: OpenCodeEvent[] = []
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page, events, [
@@ -55,13 +55,12 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
status: {
selector: `${contextSelector} [data-component="tool-status-title"]`,
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
selector: `${contextSelector} [data-component="context-tool-group-trigger"]`,
},
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
following: {
@@ -89,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -108,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Exploring", "Explored"])
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -209,13 +208,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
const content: SessionMessageAssistant["content"] = target
? [
toolContent(
contextTool(
contextIDs[0]!,
assistantID,
"read",
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status),
),
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
toolContent(
@@ -83,6 +83,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -26,7 +26,9 @@ for (const expanded of [false, true]) {
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used Shell" })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -1,12 +1,9 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [
{ locale: "de", label: "Erkundung abgeschlossen" },
{ locale: "ar", label: "تم الاستكشاف" },
] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
for (const locale of ["de", "ar"] as const) {
test(`projects localized tool names with an English fallback in ${locale}`, async ({ page }) => {
const ids = [`prt_locale_${locale}_01_read`, `prt_locale_${locale}_02_glob`]
await setupTimeline(page, {
messages: [
userMessage(),
@@ -15,11 +12,12 @@ for (const profile of [
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]),
],
locale: profile.locale,
locale,
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -53,9 +53,14 @@ test.describe("session timeline projection", () => {
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
).toBeVisible()
const first = page.locator(
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
)
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"prt_webfetch",
"prt_websearch",
@@ -78,8 +83,7 @@ test.describe("session timeline projection", () => {
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible()
await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(edit).toContainText("Edit")
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
@@ -87,6 +91,7 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage([
@@ -13,7 +13,7 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
@@ -25,9 +25,112 @@ test("groups singleton and separated context operations at correct boundaries",
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
const group = page.locator(
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
const parts = [
shell("prt_stack_shell_1", "completed", "first"),
toolPart("prt_stack_explore", "subagent", "completed", {
agent: "explore",
description: "Inspect the project",
prompt: "Explore the project",
}),
toolPart("prt_stack_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
shell("prt_stack_shell_2", "completed", "second"),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
await expect(group.locator('[data-timeline-part-id="prt_stack_shell_1"]')).toBeVisible()
await expect(group.locator('[data-timeline-part-id="prt_stack_patch"]')).toBeVisible()
await expect(group.locator('[data-component="context-tool-group-list"]')).toHaveCSS("row-gap", "8px")
const content = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-content"]')
await expect(content).toHaveCSS("margin-left", "0px")
await expect(content).toHaveCSS("padding-left", "12px")
await expect.poll(() => content.evaluate((element) => getComputedStyle(element, "::before").content)).toBe("none")
})
test("leaves tools expanded by settings outside the collapsed stack", async ({ page }) => {
const parts = [
shell("prt_expanded_shell", "completed", "expanded"),
toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }),
]
await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts)],
settings: { shellToolPartsExpanded: true },
})
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
"prt_error_glob",
"glob",
"error",
{ path: "C:/Users", pattern: "*.ts" },
{
error: "Invalid tool input",
},
),
toolPart(
"prt_error_grep",
"grep",
"error",
{ path: "C:/Users", pattern: "value" },
{
error: "Search timed out after 30 seconds",
},
),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-circle-exclamation",
)
await expect
.poll(() =>
glob
.locator('[data-kind="tool-error-card"]')
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -21,6 +21,9 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
await group.getByRole("button").click()
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
@@ -33,6 +36,7 @@ test("transitions shell and question through running error outcomes", async ({ p
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -44,7 +48,6 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
@@ -68,6 +71,7 @@ test("preserves surviving grouped patch state when its first patch fails", async
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -147,10 +151,12 @@ test("labels all web search provider variants", async ({ page }) => {
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
const tools = page.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
test("labels completed searches with result counts", async ({ page }) => {
@@ -188,6 +194,33 @@ test("labels read tools from their path input", async ({ page }) => {
).toContainText("a.ts")
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
id,
"read",
"completed",
{ path: "src/a.ts" },
{ metadata: { loaded: ["AGENTS.md", "packages/app/AGENTS.md", "packages/ui/AGENTS.md"] } },
),
]),
],
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = tool.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded AGENTS.md, packages/app/AGENTS.md, packages/ui/AGENTS.md")
await expect(loaded.locator('[data-slot="tool-loaded-value"]')).toHaveText(
"AGENTS.md, packages/app/AGENTS.md, packages/ui/AGENTS.md",
)
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
@@ -201,18 +234,40 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
],
})
for (const [id, name] of [
[pending, "frontend-design"],
[completed, "OpenCode"],
] as const) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
}
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
test("groups only consecutive successful skill tools", async ({ page }) => {
const parts = [
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
toolPart("prt_skill_second", "skill", "completed", { id: "effect" }),
toolPart("prt_skill_third", "skill", "completed", { id: "ui-pr-screenshots" }),
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(2)
await expect(loaded.nth(0)).toHaveAttribute("aria-label", "Loaded ocpr, effect, ui-pr-screenshots skills")
await expect(loaded.nth(1)).toHaveAttribute("aria-label", "Loaded opencode skill")
})
function questionInput() {
@@ -42,8 +42,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
url.pathname === `/api/session/${childID}/message` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
@@ -53,6 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,6 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -194,6 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -103,6 +103,127 @@ test("cramped tabs only show the close button for the active tab", async ({ page
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
})
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const tabA = sidebar.locator(`[data-titlebar-tab-link][href="${hrefA}"]`)
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar).toHaveCSS("width", "260px")
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
const handle = sidebar.locator('[data-component="resize-handle"]')
await expect(handle).toHaveCSS("cursor", "col-resize")
const box = await handle.boundingBox()
if (!box) throw new Error("vertical tab resize handle has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 - 80, box.y + box.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "180px")
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
const resized = await handle.boundingBox()
if (!resized) throw new Error("resized vertical tab handle has no bounding box")
await page.mouse.move(resized.x + resized.width / 2, resized.y + resized.height / 2)
await page.mouse.down()
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "130px")
await tabB.click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabB).toBeVisible()
})
test("appearance experimental setting switches tab orientation", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionA }]),
)
},
{ server, sessionA: sessionA.id },
)
await page.goto("/")
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
const layout = settings.locator('[data-action="settings-tab-layout"]')
await expect(layout).toContainText("Horizontal")
await layout.click()
await page.getByRole("option", { name: "Vertical" }).click()
await expect(layout).toContainText("Vertical")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionA }]),
)
},
{ server, sessionA: sessionA.id },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator('[data-slot="titlebar-tabs"]')
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page.locator('[data-slot="vertical-tabs-sidebar"]').locator(`[data-titlebar-tab-link][href="${href}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
function session(id: string, title: string) {
return {
id,
+2 -2
View File
@@ -58,7 +58,7 @@ export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
onThemeApplied?: () => void
onThemeApplied?: (mode: "light" | "dark", scheme: "system" | "light" | "dark") => void
}>,
) {
return (
@@ -67,7 +67,7 @@ export function AppBaseProviders(
<ThemeProvider
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
props.onThemeApplied?.()
props.onThemeApplied?.(mode, scheme)
}}
>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
@@ -133,7 +133,7 @@ export function createNewSessionComposerAdapter(props: {
return {
adapter,
project: createComposerProjectControls({ draftId: props.draftID }),
project: createComposerProjectControls({ draftId: props.draftID, worktree: props.worktree }),
model,
ready: prompt.ready,
}
@@ -4,10 +4,11 @@ import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerSDK } from "@/runtime/server/client"
import { serverName, ServerConnection, useServers } from "@/runtime/server/registry"
import { useWorkspaceLocation } from "@/workspaces/location"
import { workspaceSelectionDestination } from "@/workspaces/paths"
import { useTabs } from "@/shell/tabs/tabs"
import type { PromptProjectControls } from "./selector"
export function createComposerProjectControls(props: { draftId: string }) {
export function createComposerProjectControls(props: { draftId: string; worktree: () => string }) {
const servers = useServers()
const serverSDK = useServerSDK()
const location = useWorkspaceLocation()
@@ -38,7 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) {
tabs.updateDraft(props.draftId, {
server: ServerConnection.key(connection),
directory: worktree,
worktree: undefined,
worktree: workspaceSelectionDestination(props.worktree(), location().directory),
branch: undefined,
})
}
@@ -5,6 +5,8 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { ServerConnection } from "@/runtime/server/registry"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
@@ -12,6 +14,7 @@ import {
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: {
@@ -57,6 +60,7 @@ export function createNewSessionWorkspaceController(input: {
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
@@ -121,7 +125,8 @@ export function createNewSessionWorkspaceController(input: {
const remember = (worktree = value()) => {
const project = currentProject()
if (!project) return
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
tabs.initializeDraftWorktrees(ServerConnection.key(serverSDK.server), sdk().directory, fallback())
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
@@ -141,6 +146,7 @@ export function createNewSessionWorkspaceController(input: {
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
remember(worktree)
},
create: (branch: string) => {
input.setSelectedBranch(branch)
@@ -160,7 +166,10 @@ export function createNewSessionWorkspaceController(input: {
const loaded = branches.latest
const list = loaded?.directory === projectRoot() ? loaded.data : []
return [
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
...new Set([
...list,
...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : []),
]),
].slice(0, 50)
},
searchBranches,
+6
View File
@@ -884,6 +884,7 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.backToApp": "Back to app",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
@@ -892,6 +893,11 @@ export const dict = {
"settings.tab.extensions": "Extensions",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
"settings.appearance.row.tabs.title": "Tabs",
"settings.appearance.row.tabs.description": "Choose how session tabs are arranged",
"settings.appearance.row.tabs.horizontal": "Horizontal",
"settings.appearance.row.tabs.vertical": "Vertical",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { queuedPromptRows } from "./queue"
const queued = [
{
id: "msg_original",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
delivery: "queue",
payload: { text: "original" },
},
{
id: "msg_replacement",
sessionID: "ses_1",
timeCreated: 2,
type: "user",
delivery: "queue",
payload: { text: "edited" },
},
] satisfies SessionInboxInfo[]
describe("queuedPromptRows", () => {
test("keeps the edited prompt to one row while its replacement is admitted", () => {
expect(queuedPromptRows(queued, { original: "msg_original", replacement: "msg_replacement" })).toEqual([
{ id: "msg_replacement", text: "edited", attachments: false },
])
})
test("keeps the original visible until its replacement appears", () => {
expect(queuedPromptRows([queued[0]], { original: "msg_original", replacement: "msg_replacement" })).toEqual([
{ id: "msg_original", text: "original", attachments: false },
])
})
test("retains unrelated queue entries", () => {
expect(queuedPromptRows(queued)).toEqual([
{ id: "msg_original", text: "original", attachments: false },
{ id: "msg_replacement", text: "edited", attachments: false },
])
})
test("keeps other prompts visible while a mutation replaces the edited prompt", () => {
const other = { ...queued[0], id: "msg_other", payload: { text: "other" } }
expect(
queuedPromptRows([queued[0], other, queued[1]], { original: "msg_original", replacement: "msg_replacement" }),
).toEqual([
{ id: "msg_other", text: "other", attachments: false },
{ id: "msg_replacement", text: "edited", attachments: false },
])
})
})
+75 -37
View File
@@ -1,6 +1,8 @@
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
@@ -34,39 +36,67 @@ export function createSessionQueue(input: {
const server = useServerSDK()
const location = useWorkspaceLocation()
const language = useLanguage()
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false })
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash } }>({})
const notify = () => showToast({ title: language.t("common.requestFailed") })
const mutation = useMutation(() => ({
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| {
type: "edit"
inboxIDs: string[]
original: string
replacement: string
item: QueuedPrompt | undefined
prompt: Prompt
text: string
delivery: ComposerDelivery
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
change.item,
change.prompt,
change.text,
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
id: change.replacement,
delivery: change.delivery,
...(change.delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
cancelEdit()
if (change.delivery === "queue")
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
},
onError: notify,
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
}))
const queued = createMemo(() =>
data.session.pending
.list(input.sessionID)
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
)
const rows = createMemo(() =>
queued().map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) > 0,
})),
)
const rows = createMemo(() => {
const replacement = mutation.isPending ? mutation.variables : undefined
return queuedPromptRows(
queued(),
replacement?.type === "edit" && replacement.delivery === "queue" ? replacement : undefined,
)
})
createEffect(() => {
const editing = state.editing
if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return
if (!editing || mutation.isPending || queued().some((item) => item.id === editing.id)) return
setState("editing", undefined)
})
onCleanup(() => cancelEdit())
const notify = () => showToast({ title: language.t("common.requestFailed") })
const run = (work: () => Promise<unknown>) => {
setState("busy", true)
return work()
.catch(() => notify())
.finally(async () => {
await data.session.pending.sync(input.sessionID).catch(() => undefined)
setState("busy", false)
})
}
const rewrite = async (inboxIDs: string[]) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
@@ -108,12 +138,12 @@ export function createSessionQueue(input: {
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const reorder = (inboxIDs: string[]) => {
if (state.busy) return Promise.resolve()
return run(() => rewrite(inboxIDs))
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
}
const edit = (id: string) => {
if (state.busy) return false
if (mutation.isPending) return false
if (state.editing?.id === id) return true
const item = queued().find((entry) => entry.id === id)
if (!item) return false
@@ -147,25 +177,22 @@ export function createSessionQueue(input: {
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
if (!editing || state.busy) return
if (!editing || mutation.isPending) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
if (pristine && delivery === "queue") return cancelEdit()
const inboxIDs = queued().map((entry) => entry.id)
void run(async () => {
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
delivery,
...(delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id })
cancelEdit()
if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id)))
mutation.mutate({
type: "edit",
inboxIDs: queued().map((entry) => entry.id),
original: editing.id,
replacement: SessionMessage.ID.create(),
item,
prompt,
text,
delivery,
})
}
const editFirst = () => {
@@ -187,7 +214,7 @@ export function createSessionQueue(input: {
cancelEdit,
editFirst,
rows,
busy: () => state.busy,
busy: () => mutation.isPending,
working: input.working,
steer,
remove,
@@ -204,6 +231,17 @@ export type SessionQueueView = Pick<
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
const replaced = replacement && items.some((item) => item.id === replacement.replacement)
return items
.filter((item) => !replaced || item.id !== replacement.original)
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) > 0,
}))
}
export function queuedPromptText(item: QueuedPrompt) {
const display = item.payload.metadata?.["displayText"]
return typeof display === "string" && display.length > 0 ? display : item.payload.text
@@ -105,6 +105,8 @@ export function createTimelineController(input: { session: TimelineSessionSource
sessionMessages: projectedMessages,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -342,12 +342,11 @@ function MessageTimelineView(
const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
const project = createMemo(() => {
const projectID = props.session.data.info()?.projectID
const value = projectID
? data.project.get(projectID)
: data.project.list().find((item) => containsDirectory(item.canonical, sessionDirectory()))
if (!value) return undefined
return { ...value, worktree: value.canonical, worktrees: [] }
const session = props.session.data.info()
const projects = server.ctx.sync.data.project
return session
? projectForSession(session, projects)
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
@@ -388,7 +387,7 @@ function MessageTimelineView(
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: props.data.sessionKey,
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
projection,
showHeader,
pinned,
@@ -8,6 +8,8 @@ export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
shellToolDefaultOpen: Accessor<boolean>
editToolDefaultOpen: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
@@ -81,6 +83,8 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status(),
input.pendingUserMessageIDs(),
input.shellToolDefaultOpen(),
input.editToolDefaultOpen(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
@@ -9,6 +9,7 @@ import { createAppearanceSettingsController, type AppearanceSettingsController }
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
const fontSettings = {
ui: {
action: "settings-ui-font",
@@ -126,6 +127,30 @@ export const SettingsAppearance: Component = () => {
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsList>
</div>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.appearance.section.experimental")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
>
<Select
data-action="settings-tab-layout"
options={tabLayoutOptions}
current={tabLayoutOptions.find((option) => option === appearance.tabs.current())}
placement="bottom-end"
gutter={6}
label={(option) =>
option === "horizontal"
? language.t("settings.appearance.row.tabs.horizontal")
: language.t("settings.appearance.row.tabs.vertical")
}
onSelect={(option) => option && appearance.tabs.select(option)}
/>
</SettingsRow>
</SettingsList>
</div>
</div>
</>
)
+3 -17
View File
@@ -1,24 +1,10 @@
import { onCleanup } from "solid-js"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSettingsSurface } from "./surface"
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
let run = 0
let dead = false
onCleanup(() => {
dead = true
})
return () => {
const current = ++run
void import("@/settings/shell").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
})
}
const settings = useSettingsSurface()
return () => settings.open(defaultValue)
}
export function useSettingsCommand() {
@@ -81,6 +81,10 @@ export function createAppearanceSettingsController() {
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
tabs: {
current: settings.appearance.tabLayout,
select: settings.appearance.setTabLayout,
},
}
}
+7
View File
@@ -8,6 +8,7 @@ export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
export type TabLayout = "horizontal" | "vertical"
export interface NotificationSettings {
agent: boolean
@@ -47,6 +48,7 @@ export interface Settings {
mono: string
sans: string
terminal: string
tabLayout: TabLayout
}
keybinds: Record<string, string>
permissions: {
@@ -135,6 +137,7 @@ const defaultSettings: Settings = {
mono: "",
sans: "",
terminal: "",
tabLayout: "horizontal",
},
keybinds: {},
permissions: {
@@ -287,6 +290,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTerminalFont(value: string) {
setStore("appearance", "terminal", value.trim() ? value : "")
},
tabLayout: withFallback(() => store.appearance?.tabLayout, defaultSettings.appearance.tabLayout),
setTabLayout(value: TabLayout) {
setStore("appearance", "tabLayout", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
+209 -14
View File
@@ -5,6 +5,90 @@
height: 100%;
}
.settings-screen {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
justify-content: center;
overflow: hidden;
background: var(--v2-background-bg-deep);
outline: none;
container: settings-screen / inline-size;
}
.settings-screen > .settings {
display: flex;
width: min(100%, 1048px);
min-width: 0;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 328px;
min-width: 328px;
padding-block: 48px;
padding-inline-start: 24px;
padding-inline-end: 104px;
border-inline-end: none;
background: transparent;
}
.settings-screen > .settings > .settings-panel {
flex: 1;
min-width: 0;
max-width: 720px;
}
.settings-screen .settings-tab-header {
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent);
}
.settings-screen .settings-tab-body {
padding-inline: 0;
}
.settings-nav {
display: flex;
width: 200px;
flex-direction: column;
gap: 16px;
}
.settings-back {
display: flex;
height: 28px;
align-items: center;
gap: 6px;
padding-inline: 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-muted);
font: inherit;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.settings-back:hover,
.settings-back:focus-visible {
background: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
}
.settings-back:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
[dir="rtl"] .settings-back-icon {
transform: scaleX(-1);
}
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
@@ -21,6 +105,7 @@
overflow-y: auto;
scrollbar-width: none;
user-select: none;
container: settings-panel / inline-size;
}
.settings-panel :is(input, textarea, [contenteditable="true"]) {
@@ -172,12 +257,16 @@
width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="select-v2"] {
max-width: 100%;
}
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}
@@ -187,27 +276,93 @@
}
@media (max-width: 639px) {
.settings-screen .settings-nav {
width: 100%;
}
.settings-screen > .settings > .settings-panel {
padding-inline-end: 16px;
}
.settings-screen .settings-tab-header {
padding-top: 24px;
}
.settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 160px;
min-width: 160px;
padding-block: 24px;
padding-inline: 12px;
}
}
.settings-nav-footer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
@container settings-screen (min-width: 800px) and (max-width: 1047px) {
.settings-screen > .settings {
padding-inline: 24px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 240px;
min-width: 240px;
padding-inline-start: 16px;
padding-inline-end: 24px;
}
}
.settings-nav-footer > span {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
@container settings-screen (max-width: 799px) {
.settings-screen .settings-nav {
width: 100%;
}
.settings-screen > .settings > .settings-panel {
padding-inline-end: 16px;
}
.settings-screen .settings-tab-header {
padding-top: 24px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 160px;
min-width: 160px;
padding-block: 24px;
padding-inline: 12px;
}
}
@container settings-panel (max-width: 520px) {
.settings-tab-header-row {
flex-wrap: wrap;
}
.settings-tab-header-row [data-component="select-v2-root"] {
width: 100%;
}
.settings-tab-header-row [data-component="select-v2"] {
width: 100%;
}
[data-component="settings-row"] {
flex-wrap: wrap;
}
[data-slot="settings-row-control"] {
width: 100%;
}
}
.settings-provider-row {
@@ -230,6 +385,12 @@
}
}
@container settings-panel (max-width: 520px) {
.settings-provider-row {
flex-wrap: wrap;
}
}
.settings-providers [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
@@ -904,6 +1065,40 @@
}
}
@container settings-panel (max-width: 520px) {
.settings-workspaces-toolbar,
.settings-workspaces-main {
align-items: flex-start;
}
.settings-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
.settings-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
@@ -949,7 +1144,7 @@
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: 280px;
width: min(280px, 100%);
padding-inline: 0 !important;
}
+35 -14
View File
@@ -1,9 +1,7 @@
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Component, createEffect, createMemo, createSignal, onCleanup, onMount, startTransition } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsGeneral } from "./general/general"
import { SettingsAppearance } from "./appearance/appearance"
import { SettingsKeybinds } from "./keybinds/keybinds"
@@ -20,19 +18,32 @@ import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useCommand } from "@/shell/commands/command"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
export const DialogSettings: Component<{
export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const command = useCommand()
const surface = useSettingsSurface()
const layout = useLayout()
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
let root: HTMLDivElement | undefined
onMount(() => {
command.keybinds(false)
root?.focus({ preventScroll: true })
})
onCleanup(() => command.keybinds(true))
createEffect(() => setTab(props.defaultValue ?? "general"))
const server = createMemo(() => {
const route = layout.route()
switch (route.type) {
@@ -67,11 +78,22 @@ export const DialogSettings: Component<{
})
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
dialog.close()
setTab("providers")
}
return (
<Dialog size="x-large" variant="settings" class="settings-dialog">
<div
ref={root}
data-testid="settings-screen"
class="settings-screen"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
event.preventDefault()
surface.close()
}}
>
<Tabs
orientation="vertical"
variant="settings"
@@ -80,7 +102,11 @@ export const DialogSettings: Component<{
class="settings"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="settings-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<div class="flex flex-col gap-1 w-full">
@@ -134,11 +160,6 @@ export const DialogSettings: Component<{
</Tabs.Trigger>
</div>
</div>
<div class="settings-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span>
</div>
</div>
</Tabs.List>
@@ -175,6 +196,6 @@ export const DialogSettings: Component<{
</Tabs.Content>
</SettingsServerScope>
</Tabs>
</Dialog>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useLocation } from "@solidjs/router"
import { createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
gate: false,
init: () => {
const location = useLocation()
const [store, setStore] = createStore({ open: false, tab: "general" })
let focus: HTMLElement | undefined
const close = () => {
if (!store.open) return
setStore("open", false)
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
}
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
return {
store,
open(tab = "general") {
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
setStore({ open: true, tab })
},
close,
}
},
})
@@ -17,6 +17,7 @@ export const SettingsProjects: Component = () => {
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const multiple = createMemo(() => global.servers.list().length > 1)
const projects = createMemo(() => {
const server = selected()
if (!server) return []
@@ -41,7 +42,7 @@ export const SettingsProjects: Component = () => {
const name = () => displayName(props.project)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
@@ -72,14 +73,16 @@ export const SettingsProjects: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
<Show when={multiple()}>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</Show>
</div>
</div>
@@ -98,7 +101,14 @@ export const SettingsProjects: Component = () => {
>
<Show when={selected()} keyed>
{(server) => (
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
</div>
</div>
)}
</Show>
</Show>
@@ -117,9 +127,11 @@ export const SettingsProjects: Component = () => {
<For each={groups()}>
{(group) => (
<div class="settings-section">
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
<Show when={multiple()}>
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
+1 -1
View File
@@ -278,7 +278,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
return (
<div
class="relative flex-1 h-screen w-screen min-h-0 overflow-y-auto flex flex-col items-center justify-start sm:justify-center p-4 sm:p-8 font-sans"
class="relative flex-1 h-full w-full min-h-0 min-w-0 overflow-y-auto flex flex-col items-center justify-start sm:justify-center p-4 sm:p-8 font-sans"
data-tauri-drag-region
>
<div class="w-full max-w-3xl flex flex-col items-center justify-center gap-6 sm:gap-8 my-auto">
@@ -12,6 +12,7 @@ import {
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "./helpers"
@@ -167,6 +168,27 @@ describe("layout workspace helpers", () => {
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
test("keeps the enriched workspace inventory when matching a session by project id", () => {
const project = {
id: "project",
worktree: "/repo",
sandboxes: ["/workspaces/feature"],
worktrees: [{ directory: "/repo" }, { directory: "/workspaces/feature", strategy: "git" }],
}
expect(projectForSession(session({ id: "feature", directory: "/workspaces/feature/packages/app" }), [project])).toBe(
project,
)
})
test("finds the enriched project for a nested workspace when its session project id is stale", () => {
const project = { id: "updated", worktree: "/repo", sandboxes: ["/workspaces/feature"] }
expect(projectForSession(session({ id: "feature", directory: "/workspaces/feature/packages/app" }), [project])).toBe(
project,
)
})
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+2 -5
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { pathKey } from "@/workspaces/path-key"
import { isProjectDirectory } from "@/workspaces/paths"
import type { ServerConnection } from "@/runtime/server/registry"
import type { HomeProjectSelection } from "@/shell/state/layout"
@@ -100,11 +101,7 @@ export function projectForSession<T extends { id?: string; worktree: string; san
) {
const direct = byID.get(session.projectID)
if (direct) return direct
const directory = pathKey(session.location.directory)
return projects.find(
(project) =>
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
)
return projects.find((project) => isProjectDirectory(project, session.location.directory))
}
export const errorMessage = (err: unknown, fallback: string) => {
+4 -1
View File
@@ -6,6 +6,7 @@ import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
@@ -69,7 +70,9 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
<Shell>{props.children}</Shell>
<SettingsSurfaceProvider>
<Shell>{props.children}</Shell>
</SettingsSurfaceProvider>
</LayoutProvider>
)
}
+49 -4
View File
@@ -1,15 +1,28 @@
import { lazy, Show, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
export default function Layout(props: ParentProps) {
const platform = usePlatform()
const [state, setState] = createStore({ debugTools: false })
const settings = useSettingsSurface()
const preferences = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({
debugTools: false,
tabsWidth: 260,
tabsMount: undefined as HTMLElement | undefined,
})
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
const update: TitlebarUpdate = {
get version() {
@@ -34,15 +47,47 @@ export default function Layout(props: ParentProps) {
>
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
style={{ width: `${state.tabsWidth}px` }}
>
<ResizeHandle
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
</aside>
</Show>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
inert={settings.store.open}
aria-hidden={settings.store.open}
>
<Suspense>{props.children}</Suspense>
</div>
<Show when={settings.store.open}>
<Suspense>
<SettingsScreen defaultValue={settings.store.tab} />
</Suspense>
</Show>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
+8
View File
@@ -245,6 +245,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
})
},
initializeDraftWorktrees(server: ServerConnection.Key, directory: string, worktree: string) {
setStore(
(tab) => tab.type === "draft" && tab.server === server && tab.directory === directory && !tab.worktree,
produce((tab) => {
if (tab.type === "draft") tab.worktree = worktree
}),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders
// after its backing draft tab has been removed from the store.
+55 -4
View File
@@ -15,6 +15,48 @@
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) {
height: 45px;
}
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) [data-slot="tab-link"] {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
grid-template-rows: repeat(2, var(--line-height-compact));
align-content: center;
column-gap: 6px;
row-gap: 0;
font-size: 13px;
line-height: var(--line-height-compact);
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="project-avatar-slot"] {
grid-column: 1;
grid-row: 1;
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-title"] {
grid-column: 2;
grid-row: 1;
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-project"] {
grid-column: 2;
grid-row: 2;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-compact);
white-space: nowrap;
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-close"] {
top: 0;
bottom: 0;
margin-block: auto;
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
@@ -39,6 +81,10 @@
gap: 6px;
}
[data-titlebar-tab-list][data-orientation="vertical"] {
gap: 2px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
@@ -68,6 +114,11 @@
display: none;
}
[data-titlebar-tab-list][data-orientation="vertical"] [data-titlebar-tab-slot]::before,
[data-titlebar-tab-slot]:has([data-titlebar-tab][data-orientation="vertical"])::before {
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
@@ -133,21 +184,21 @@
}
@container (max-width: 64px) {
[data-titlebar-tab-link] {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
justify-content: center;
gap: 0;
padding-inline: 0;
}
[data-titlebar-tab-title] {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
display: none;
}
[data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-active="true"]) [data-slot="tab-close"] {
display: none;
}
[data-slot="tab-close"] {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
@@ -33,6 +33,7 @@ export function TabNavItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
orientation?: "horizontal" | "vertical"
}) {
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
@@ -180,6 +181,7 @@ export function TabNavItem(props: {
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-orientation={props.orientation ?? "horizontal"}
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
@@ -278,6 +280,13 @@ export function TabNavItem(props: {
event.preventDefault()
}}
/>
<Show when={props.orientation === "vertical" && projectName()}>
{(name) => (
<span data-slot="tab-project" dir="auto">
{name()}
</span>
)}
</Show>
</a>
<div data-slot="tab-close">
@@ -299,6 +308,7 @@ export function TabNavItem(props: {
return (
<TabPreviewPopover
trigger={tab}
orientation={props.orientation}
open={popoverOpen() && !previewBlocked()}
onOpenChange={(value) => {
if (value && previewBlocked()) return
@@ -325,6 +335,7 @@ export function DraftTabItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
@@ -337,6 +348,7 @@ export function DraftTabItem(props: {
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-orientation={props.orientation ?? "horizontal"}
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
@@ -8,6 +8,7 @@
z-index: 50;
box-sizing: border-box;
width: 256px;
max-width: calc(100dvw - 24px);
display: flex;
flex-direction: column;
gap: 6px;
@@ -1,9 +1,10 @@
import { HoverCard } from "@kobalte/core/hover-card"
import { createSignal, Show, type JSXElement } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 2_000
const OPEN_DELAY = 750
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -24,7 +25,9 @@ export function TabPreviewPopover(props: {
open: boolean
onOpenChange: (open: boolean) => void
data: TabPreviewData
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
let triggerEl: HTMLDivElement | undefined
// When opened during a rapid tab-hopping streak, this preview appears and
// disappears instantly (no repeated enter/exit animation) — only the first,
@@ -50,7 +53,13 @@ export function TabPreviewPopover(props: {
// The preview is non-interactive (pointer-events: none), so there is no
// safe area to traverse — leaving the tab hides it immediately.
ignoreSafeArea
placement="bottom-start"
placement={
props.orientation === "vertical"
? language.direction() === "rtl"
? "left-start"
: "right-start"
: "bottom-start"
}
gutter={6}
>
<HoverCard.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
+62 -20
View File
@@ -4,7 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToHorizontalAxis, RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/shell/tabs/tabs"
@@ -27,6 +27,7 @@ function SessionTabSlot(props: {
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
@@ -49,7 +50,12 @@ function SessionTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
<TabNavItem
ref={(el) => {
@@ -65,6 +71,7 @@ function SessionTabSlot(props: {
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
</div>
)
@@ -76,6 +83,7 @@ function SessionTabEntry(props: {
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
@@ -160,6 +168,7 @@ function SessionTabEntry(props: {
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
orientation={props.orientation}
session={session()}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename}
@@ -175,6 +184,7 @@ function DraftTabSlot(props: {
id: string
index: number
active: boolean
orientation: "horizontal" | "vertical"
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -195,7 +205,12 @@ function DraftTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
<DraftTabItem
ref={(el) => {
@@ -207,12 +222,14 @@ function DraftTabSlot(props: {
onClose={props.onClose}
active={props.active}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
</div>
)
}
export function TitlebarTabStrip(props: {
orientation?: "horizontal" | "vertical"
tabs: Tab[]
currentTab: Tab | undefined
forceTruncate: boolean
@@ -224,6 +241,7 @@ export function TitlebarTabStrip(props: {
const global = useGlobal()
const language = useLanguage()
const command = useCommand()
const vertical = () => props.orientation === "vertical"
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
@@ -259,7 +277,9 @@ export function TitlebarTabStrip(props: {
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
props.onOverflowChange(
vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
)
}
createResizeObserver(
@@ -288,10 +308,19 @@ export function TitlebarTabStrip(props: {
})
return (
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
data-orientation={vertical() ? "vertical" : "horizontal"}
class="relative min-w-0"
classList={{ "min-h-0 overflow-hidden": vertical() }}
>
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
data-slot={vertical() ? "vertical-tabs-scroll" : "titlebar-tabs-scroll"}
class="flex min-w-0 no-scrollbar [app-region:no-drag]"
classList={{
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
}}
ref={scrollRef}
>
<DragDropProvider
@@ -304,10 +333,13 @@ export function TitlebarTabStrip(props: {
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
modifiers={[
vertical() ? RestrictToVerticalAxis : RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => listRef }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
AutoScroller.configure({ acceleration: 8, threshold: vertical() ? { x: 0, y: 0.05 } : { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragStart={(event) => {
@@ -335,7 +367,13 @@ export function TitlebarTabStrip(props: {
}
}}
>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<div
data-titlebar-tab-list
data-orientation={vertical() ? "vertical" : "horizontal"}
class="flex w-full min-w-0"
classList={{ "flex-row items-center": !vertical(), "flex-col items-stretch": vertical() }}
ref={listRef}
>
<For each={props.tabs}>
{(tab) => {
const id = tabKey(tab)
@@ -355,6 +393,7 @@ export function TitlebarTabStrip(props: {
index={visibleIndex()}
active={props.currentTab === tab}
forceTruncate={props.forceTruncate}
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => {
@@ -372,6 +411,7 @@ export function TitlebarTabStrip(props: {
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
@@ -385,16 +425,18 @@ export function TitlebarTabStrip(props: {
</div>
</DragDropProvider>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
<Show when={!vertical()}>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</Show>
</div>
)
}
+80 -33
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -36,7 +37,11 @@ export type TitlebarUpdate = {
install: () => void
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
export function Titlebar(props: {
update?: TitlebarUpdate
debugTools?: { visible: boolean; toggle: () => void }
verticalTabs?: { mount?: HTMLElement }
}) {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
@@ -54,7 +59,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -324,7 +329,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
classList={{
"pt-2": !bottom(),
"pt-2": !bottom() && !windows(),
"pb-2": bottom(),
"md:pl-2": macTrafficLights(),
"md:pl-4": !macTrafficLights(),
@@ -357,40 +362,82 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
/>
</Tooltip>
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<Show
when={props.verticalTabs}
fallback={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal mount={mount}>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={false}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<button
type="button"
data-action="vertical-tabs-new-session"
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
{language.t("command.session.new")}
</button>
</Portal>
)}
</Show>
)}
</Show>
<div class="flex-1" />
<TitlebarRight state={rightState()} />
</div>
+1 -1
View File
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
{
location: { directory: sdk().directory },
query,
type: dirs === "true" ? "directory" : "file",
type: dirs === "true" ? undefined : "file",
limit: options?.limit,
},
{ signal: options?.signal },
+14
View File
@@ -8,6 +8,7 @@ import {
mergeWorkspaceSessionInventory,
sessionsForWorkspace,
workspaceInventory,
workspaceSelectionDestination,
} from "./paths"
describe("isWorkspaceDirectory", () => {
@@ -41,6 +42,19 @@ describe("isWorkspaceSelection", () => {
})
})
describe("workspaceSelectionDestination", () => {
test("preserves local intent for the main selection and project root", () => {
expect(workspaceSelectionDestination("main", "/repo")).toBe("main")
expect(workspaceSelectionDestination("/repo/", "/repo")).toBe("main")
expect(workspaceSelectionDestination("c:\\repo\\", "C:\\repo")).toBe("main")
})
test("preserves workspace intent without carrying project-specific paths", () => {
expect(workspaceSelectionDestination("create", "/repo")).toBe("create")
expect(workspaceSelectionDestination("/workspaces/feature", "/repo")).toBe("create")
})
})
test("groups and filters workspace inventory by project", () => {
const inventory = workspaceInventory([
{
+4
View File
@@ -106,6 +106,10 @@ export function isWorkspaceSelection(project: WorkspaceProject | undefined, sele
return isWorkspaceDirectory(project, selection)
}
export function workspaceSelectionDestination(selection: string, projectWorktree: string) {
return selection === "main" || sameDirectory(selection, projectWorktree) ? "main" : "create"
}
export function workspaceDefaultSelection(
setting: WorkspaceDefaultDestination,
lastUsed: WorkspaceLastUsed | undefined,
+1
View File
@@ -27,6 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+2 -1
View File
@@ -62,7 +62,8 @@ const targets =
: allTargets
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
if (!skipInstall)
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]} @opencode-ai/pty@${pkg.dependencies["@opencode-ai/pty"]}`
const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi })
const appAssetsPlugin: BunPlugin = {
name: "opencode-app-assets",
+22 -82
View File
@@ -1,20 +1,8 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
const VERSION = "0.1.5"
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
@@ -27,76 +15,28 @@ type Target = {
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
const core = createRequire(path.resolve(import.meta.dirname, "../../core/package.json"))
const pty = createRequire(core.resolve("@opencode-ai/pty/package.json"))
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
export async function resolveOpencodePty(target: Target): Promise<OpencodePtyAsset | undefined> {
if (target.platform !== "darwin" && target.platform !== "linux") return undefined
if (target.arch !== "arm64" && target.arch !== "x64") return undefined
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
const suffix = [target.platform, target.arch, target.platform === "linux" ? (target.libc ?? "glibc") : undefined]
.filter(Boolean)
.map((value) => (value === "glibc" ? "gnu" : value))
.join("-")
const name = `@opencode-ai/pty-${suffix}`
const source = pty.resolve(`${name}/bin/opencode-pty`)
const manifest: unknown = JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
if (!manifest || typeof manifest !== "object" || !("version" in manifest) || typeof manifest.version !== "string")
throw new Error(`Invalid package metadata for ${name}`)
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
return {
source,
version: manifest.version,
sha256: createHash("sha256")
.update(await readFile(source))
.digest("hex"),
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
+35
View File
@@ -663,6 +663,15 @@ export type SessionLogOutput =
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.streamed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly assistantMessageID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: number
@@ -974,6 +983,19 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly to: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.message.content.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<SessionMessage.AssistantContentEncoded>
}
}
| {
readonly id: Event.ID
readonly created: number
@@ -1013,6 +1035,18 @@ export type SessionMessageInput = { readonly sessionID: Session.ID; readonly mes
export type SessionMessageOutput = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: SessionMessageInput) => Effect.Effect<SessionMessageOutput, E>
export type SessionMessageUpdateInput = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly content: ReadonlyArray<
SessionMessage.AssistantText | SessionMessage.AssistantReasoning | SessionMessage.AssistantTool
>
}
export type SessionMessageUpdateOutput = SessionMessage.Assistant
export type SessionMessageUpdateOperation<E = never> = (
input: SessionMessageUpdateInput,
) => Effect.Effect<SessionMessageUpdateOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1071,6 +1105,7 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly messageUpdate: SessionMessageUpdateOperation<E>
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -86,6 +86,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionMessageUpdateInput,
SessionMessageUpdateOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -651,6 +653,17 @@ const EndpointSessionMessage = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionMessageUpdate = (raw: RawClient["server.session"]) => (input: SessionMessageUpdateInput) =>
preserveEffect<SessionMessageUpdateOutput>()(
raw["session.messageUpdate"]({
params: { sessionID: input["sessionID"], messageID: input["messageID"] },
payload: { content: input["content"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -711,6 +724,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: EndpointSessionMessage(raw),
messageUpdate: EndpointSessionMessageUpdate(raw),
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -80,6 +80,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionMessageUpdateInput,
SessionMessageUpdateOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -929,6 +931,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
messageUpdate: (input: SessionMessageUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionMessageUpdateOutput }>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
body: { content: input["content"] },
successStatus: 200,
declaredStatuses: [404, 400, 409, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
+201 -42
View File
@@ -174,6 +174,12 @@ export type SessionMessageProviderState1 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
@@ -293,7 +299,7 @@ export type McpResourceTemplate = {
mimeType?: string
}
export type ProjectVcs = "git" | "hg"
export type ProjectVcs = string
export type ProjectIcon = { url?: string; override?: string; color?: string }
@@ -714,6 +720,16 @@ export type SessionStepStarted = {
data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string }
}
export type SessionStepStreamed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.step.streamed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string }
}
export type SessionTextStarted = {
id: string
created: number
@@ -974,7 +990,7 @@ export type WorktreeResolved = {
type: "worktree.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
data: { projectID: string; directory: string; previous: string; adopted?: Array<string> }
}
export type CommandUpdated = {
@@ -1315,6 +1331,15 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = {
@@ -1743,6 +1768,21 @@ export type SessionToolFailed = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type ModelInfo = {
id: string
modelID: string
@@ -2009,6 +2049,21 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
name: string
executed?: boolean
providerState?: SessionMessageProviderState1
providerResultState?: SessionMessageProviderState1
state:
| SessionMessageToolStateStreaming
| SessionMessageToolStateRunning1
| SessionMessageToolStateCompleted1
| SessionMessageToolStateError1
time: { created: number; ran?: number; completed?: number }
}
export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
@@ -2028,7 +2083,7 @@ export type SessionInboxEnqueued = {
export type SessionMessageAssistant = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number; completed?: number }
time: { created: number; streamed?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
@@ -2043,6 +2098,11 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
| SessionMessageAssistantTool1
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
@@ -2051,6 +2111,50 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 }
export type SessionMessageInfo =
| SessionMessageAgentSelected
| SessionMessageModelSelected
| SessionMessageLocationSwitched
| SessionMessageUser
| SessionMessageSynthetic
| SessionMessageSystem
| SessionMessageSkill
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionMessageContentUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.message.content.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; messageID: string; content: Array<SessionMessageAssistantContentEncoded> }
}
export type IntegrationMethod =
| IntegrationOAuthMethod
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
export type FormCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "form.created"
location?: LocationRef
data: { form: FormInfo1 }
}
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null }
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
@@ -2074,6 +2178,7 @@ export type SessionEventDurable =
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepStreamed
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
@@ -2092,44 +2197,9 @@ export type SessionEventDurable =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionMessageContentUpdated
| SessionUsageRecorded
export type SessionMessageInfo =
| SessionMessageAgentSelected
| SessionMessageModelSelected
| SessionMessageLocationSwitched
| SessionMessageUser
| SessionMessageSynthetic
| SessionMessageSystem
| SessionMessageSkill
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
export type IntegrationMethod =
| IntegrationOAuthMethod
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
export type FormCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "form.created"
location?: LocationRef
data: { form: FormInfo1 }
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null }
}
export type IntegrationInfo = {
id: string
name: string
@@ -2168,6 +2238,7 @@ export type V2Event =
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepStreamed
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
@@ -2191,6 +2262,7 @@ export type V2Event =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionMessageContentUpdated
| FilesystemChanged
| ReferenceUpdated
| PermissionAsked
@@ -2229,6 +2301,8 @@ export type V2Event =
| McpResourcesChanged
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -2789,7 +2863,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3065,7 +3139,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3341,7 +3415,7 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: { readonly created: number; readonly streamed?: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -4036,6 +4110,91 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionMessageUpdateInput = {
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
readonly content: {
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly metadata: { readonly [x: string]: JsonValue }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
}
>
}["content"]
}
export type SessionMessageUpdateOutput = { data: SessionMessageAssistant }["data"]
export type SessionEnvironmentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
+38 -1
View File
@@ -167,6 +167,10 @@ function createSync() {
has(key: string) {
return state.has(key)
},
pending(key: string) {
const active = state.get(key)
return active !== undefined && active !== true
},
invalidate(key?: string) {
if (key) {
const active = state.get(key)
@@ -617,7 +621,22 @@ export function createData(config: CreateDataInput) {
}
case "worktree.resolved": {
for (const [sessionID, info] of Object.entries(store.session.info)) {
const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data)
const explicit = event.data.adopted?.includes(info.projectID)
const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory
if (!directory) {
if (info.location.workspaceID) continue
result.session.invalidate(sessionID)
void result.session.sync(sessionID)
continue
}
const adopted = Worktree.adopt(
{
projectID: info.projectID,
directory,
workspaceID: info.location.workspaceID,
},
event.data,
)
if (!adopted) continue
setStore("session", "info", sessionID, "projectID", adopted.projectID)
setStore("session", "info", sessionID, "subpath", adopted.subpath)
@@ -708,6 +727,17 @@ export function createData(config: CreateDataInput) {
match.time.completed = event.created
})
return
case "session.message.content.updated": {
if (store.session.message[event.data.sessionID])
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.messageID)
if (assistant) assistant.content = [...event.data.content]
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
void result.session.message.sync(event.data.sessionID)
return
}
case "session.step.started":
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.assistantMessageID)
@@ -720,6 +750,7 @@ export function createData(config: CreateDataInput) {
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.streamed = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -741,6 +772,12 @@ export function createData(config: CreateDataInput) {
})
})
return
case "session.step.streamed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (currentAssistant) currentAssistant.time.streamed = event.created
})
return
case "session.step.ended": {
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
+30 -1
View File
@@ -1,4 +1,4 @@
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
import type { ExperimentalPersistentPtyConnectTokenInput, OpenCodeClient, PtyConnectTokenInput } from "../promise"
export type PtyClientOptions = {
readonly url: string
@@ -11,6 +11,13 @@ export type PtyConnectInput = {
readonly cursor?: number
}
export type PersistentPtyConnectInput = {
readonly ptyID: ExperimentalPersistentPtyConnectTokenInput["ptyID"]
readonly cursor: number
readonly attachmentID: string
readonly takeover?: boolean
}
export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PtyConnectInput) {
@@ -32,3 +39,25 @@ export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions)
},
}
}
export function createPersistentPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PersistentPtyConnectInput) {
const token = await api.experimental.persistentPty.connectToken({
ptyID: input.ptyID,
"x-opencode-ticket": "1",
})
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
url.searchParams.set("ticket", token.ticket)
url.searchParams.set("cursor", String(input.cursor))
url.searchParams.set("attachment_id", input.attachmentID)
url.searchParams.set("takeover", String(input.takeover ?? false))
url.searchParams.set("input_protocol", "1")
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
const socket = options.openSocket?.(url) ?? new WebSocket(url)
socket.binaryType = "arraybuffer"
return socket
},
}
}
+171
View File
@@ -154,6 +154,106 @@ test("updates authoritative cached project metadata from live events", async ()
}
})
test("adopts cached directory-project sessions when their repository is resolved", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const refreshed: SessionInfo = {
...session(0),
id: "ses_uncached",
projectID: "repository",
location: { directory: "/unknown-alias" },
subpath: "app",
}
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/session/ses_uncached")) throw new Error(`Unexpected request: ${request.url}`)
return Response.json({ data: refreshed })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/repo",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
const sessions: SessionInfo[] = [
{ ...session(0), id: "ses_root", projectID: "directory-root", location: { directory: "/repo" } },
{ ...session(0), id: "ses_nested", projectID: "directory-nested", location: { directory: "/repo/app" } },
{
...session(0),
id: "ses_alias",
projectID: "directory-nested",
location: { directory: "/repo/alias/../app" },
},
{ ...session(0), id: "ses_symlink", projectID: "directory-nested", location: { directory: "/shortcut" } },
{ ...refreshed, projectID: "directory-uncached" },
{ ...session(0), id: "ses_global", projectID: "global", location: { directory: "/repo/legacy" } },
{ ...session(0), id: "ses_escaped", projectID: "global", location: { directory: "/repo/../other" } },
{ ...session(0), id: "ses_other", projectID: "other-repository", location: { directory: "/repo/vendor" } },
{ ...session(0), id: "ses_sibling", projectID: "global", location: { directory: "/repo-other" } },
{
...session(0),
id: "ses_remote",
projectID: "directory-root",
location: { directory: "/repo", workspaceID: "workspace-remote" },
},
]
sessions.forEach((item) => setup.data.session.remember(item))
for (const project of [
{ id: "directory-root", canonical: "/repo" },
{ id: "directory-nested", canonical: "/repo/app" },
]) {
const updated: OpenCodeEvent = {
id: `evt_${project.id}`,
created: 0,
type: "project.updated",
data: { ...project, time: { created: 0, updated: 0 }, sandboxes: [] },
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
}
const resolved: OpenCodeEvent = {
id: "evt_repository_resolved",
created: 1,
type: "worktree.resolved",
durable: { aggregateID: "repository", seq: 0, version: 1 },
data: {
projectID: "repository",
directory: "/repo",
previous: "global",
adopted: ["directory-root", "directory-nested", "directory-uncached"],
},
}
listeners.forEach((listener) => listener({ name: resolved.type, details: resolved }))
expect(setup.data.session.get("ses_root")?.projectID).toBe("repository")
expect(setup.data.session.get("ses_root")?.subpath).toBeUndefined()
expect(setup.data.session.get("ses_nested")).toMatchObject({ projectID: "repository", subpath: "app" })
expect(setup.data.session.get("ses_alias")).toMatchObject({ projectID: "repository", subpath: "app" })
expect(setup.data.session.get("ses_symlink")).toMatchObject({ projectID: "repository", subpath: "app" })
expect(setup.data.session.get("ses_global")).toMatchObject({ projectID: "repository", subpath: "legacy" })
expect(setup.data.session.get("ses_escaped")?.projectID).toBe("global")
expect(setup.data.session.get("ses_other")?.projectID).toBe("other-repository")
expect(setup.data.session.get("ses_sibling")?.projectID).toBe("global")
expect(setup.data.session.get("ses_remote")?.projectID).toBe("directory-root")
await wait(() => setup.data.session.get("ses_uncached")?.projectID === "repository")
expect(setup.data.session.get("ses_uncached")?.subpath).toBe("app")
} finally {
setup.dispose()
}
})
test("refreshes global credential events across every loaded location and workspace", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
@@ -314,6 +414,77 @@ test("loads bounded message pages", async () => {
}
})
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
let requests = 0
const content = [
{ type: "text" as const, text: "replacement" },
{ type: "reasoning" as const, text: "reasoning", time: { created: 3 } },
]
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
const current = ++requests
if (current === 2) await release.promise
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: current === 3 ? content : [{ type: "text", text: "original" }],
time: { created: 1, completed: 2 },
},
],
cursor: {},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
setup.data.session.message.invalidate("ses_refresh")
const stale = setup.data.session.message.sync("ses_refresh")
await wait(() => requests === 2)
const updated: OpenCodeEvent = {
id: "evt_message_updated",
created: 3,
type: "session.message.content.updated",
durable: { aggregateID: "ses_refresh", seq: 3, version: 1 },
data: {
sessionID: "ses_refresh",
messageID: "msg_assistant",
content,
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
release.resolve()
await stale
await wait(() => requests === 3)
expect(setup.data.session.message.list("ses_refresh")[0]).toMatchObject({ content })
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
+34 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "../src/promise"
import { createPtyClient } from "../src/solid"
import { createPersistentPtyClient, createPtyClient } from "../src/solid"
describe("createPtyClient", () => {
test("mints an authenticated ticket before opening the terminal socket", async () => {
@@ -66,3 +66,36 @@ describe("createPtyClient", () => {
expect(opened).toBe(false)
})
})
describe("createPersistentPtyClient", () => {
test("mints an authenticated ticket before opening the persistent terminal socket", async () => {
let request: Request | undefined
let socketURL: URL | undefined
const socket = { binaryType: "blob" } as unknown as WebSocket
const api = OpenCode.make({
baseUrl: "https://server.example/base",
headers: { Authorization: "Basic credential" },
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: { ticket: "persistent-ticket", expires_in: 60 } })
},
})
const pty = createPersistentPtyClient(api, {
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return socket
},
})
expect(await pty.connect({ ptyID: "pty_1", cursor: 42, attachmentID: "attachment_1", takeover: true })).toBe(socket)
expect(request?.method).toBe("POST")
expect(request?.url).toBe("https://server.example/api/experimental/persistent-pty/pty_1/connect-token")
expect(request?.headers.get("authorization")).toBe("Basic credential")
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
expect(socketURL?.toString()).toBe(
"wss://server.example/api/experimental/persistent-pty/pty_1/connect?ticket=persistent-ticket&cursor=42&attachment_id=attachment_1&takeover=true&input_protocol=1",
)
expect(socket.binaryType).toBe("arraybuffer")
})
})
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
describe("Worktree.adopt", () => {
const event = {
projectID: "repository",
directory: "/repo",
previous: "previous",
adopted: ["directory-root", "directory-nested"],
}
test("adopts explicitly superseded directory projects", () => {
expect(Worktree.adopt({ projectID: "directory-root", directory: "/repo" }, event)).toEqual({
projectID: "repository",
subpath: undefined,
})
expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
})
test("preserves existing previous-project and global adoption", () => {
expect(Worktree.adopt({ projectID: "previous", directory: "/repo/packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
expect(Worktree.adopt({ projectID: "global", directory: "/repo/packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
})
test("leaves unrelated projects, sibling directories, and workspaces unchanged", () => {
expect(Worktree.adopt({ projectID: "other", directory: "/repo/vendor" }, event)).toBeUndefined()
expect(Worktree.adopt({ projectID: "global", directory: "/repo-other" }, event)).toBeUndefined()
expect(Worktree.adopt({ projectID: "repository", directory: "/repo" }, event)).toBeUndefined()
expect(
Worktree.adopt({ projectID: "directory-root", directory: "/repo", workspaceID: "remote" }, event),
).toBeUndefined()
})
test("normalizes Windows directory separators", () => {
expect(
Worktree.adopt(
{ projectID: "directory-nested", directory: "C:\\repo\\packages\\app" },
{ ...event, directory: "C:\\repo" },
),
).toEqual({ projectID: "repository", subpath: "packages/app" })
expect(
Worktree.adopt(
{ projectID: "directory-nested", directory: "c:/Repo/packages/App" },
{ ...event, directory: "C:\\repo" },
),
).toEqual({ projectID: "repository", subpath: "packages/App" })
})
test("normalizes aliases and rejects paths that leave the repository", () => {
expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/alias/../packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
expect(Worktree.adopt({ projectID: "global", directory: "/repo/../other" }, event)).toBeUndefined()
expect(Worktree.adopt({ projectID: "global", directory: "/app" }, { ...event, directory: "/" })).toEqual({
projectID: "repository",
subpath: "app",
})
})
test("decodes existing durable events without adopted project IDs", () => {
expect(
Schema.decodeUnknownSync(Worktree.Event.Resolved.data)({
projectID: "repository",
directory: "/repo",
previous: "global",
}),
).toEqual({ projectID: "repository", directory: "/repo", previous: "global" })
})
})
+1
View File
@@ -119,6 +119,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+13 -5
View File
@@ -19,7 +19,16 @@ const result = await Bun.build({
target: "node",
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
external: [
"#sqlite",
"#pty",
"#persistent-pty-binary",
"#fff",
"#photon-wasm",
"#shell-parser-wasm",
"#process-lock-ffi",
"#v1-migration",
],
splitting: true,
loader: {
".txt": "text",
@@ -51,16 +60,15 @@ const rewritten = await Promise.all(
.replace(eagerRequire, "")
if (/\bnew\s+__require\s*\(/.test(generatedUses))
throw new Error(`Unsupported generated require constructor in ${output.path}`)
const unsupported = generatedUses
.replace(/\b__require\.resolve\s*\(/g, "")
.replace(/\b__require\s*\(/g, "")
const unsupported = generatedUses.replace(/\b__require\.resolve\s*\(/g, "").replace(/\b__require\s*\(/g, "")
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
if (!source.includes(eagerRequire)) return false
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
throw new Error(`Multiple eager require helpers in ${output.path}`)
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
if (rewrittenSource.includes(eagerRequire)) throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
if (rewrittenSource.includes(eagerRequire))
throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
await Bun.write(output.path, rewrittenSource)
return true
}),
+7 -68
View File
@@ -4,12 +4,13 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
import { PluginSourceDirectory } from "../../plugin/source-directory.js"
export type Operation =
| {
@@ -124,7 +125,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
(entry) =>
PluginSourceDirectory.discover(fs, entry.path).pipe(
Effect.map((targets) => targets.map((target): Operation => ({ type: "add", target, options: {} }))),
),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
@@ -153,75 +157,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) =>
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
})
}
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
return Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
return Option.none<string>()
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
PluginSourceDirectory.names.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
+7 -2
View File
@@ -6,6 +6,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -64,6 +65,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
@@ -90,7 +92,10 @@ const layer = Layer.effect(
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
@@ -103,5 +108,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
@@ -6,6 +6,7 @@ import asset from "./pty-binding.js"
export async function resolveBinary(bin: string) {
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
if (!asset) return "opencode-pty"
if (typeof asset === "string") return asset
return install(bin, asset)
}

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