Compare commits

..

1 Commits

Author SHA1 Message Date
Hona 4a70e6c8ba fix(ui): prevent text shimmer overlap 2026-08-22 02:15:43 +00:00
204 changed files with 2520 additions and 5307 deletions
+1
View File
@@ -27,6 +27,7 @@ jobs:
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.0
bun-version: canary # Bun 1.4 until its stable release is published
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.0
BUN_COMPILE_RELEASE: canary
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+588 -318
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
"x86_64-linux": "sha256-xRvq8FkSjn+1q+1wcab+jAmQJdo8lHF8OGnzU+xPLyI=",
"aarch64-linux": "sha256-uAwwtOz81LLimTqiDH6E1W65srSMLGk1QV0cUJYanj0=",
"aarch64-darwin": "sha256-F28kZvtYRb+ri5T8JoIG/VMK6J7ad5R3QivwJDLtmWA=",
"x86_64-darwin": "sha256-NWNoBjwQTTNAESdFWItT4UYc+fTLD1RIe/Ibbqtem7Q="
}
}
+2 -2
View File
@@ -52,7 +52,7 @@
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@tanstack/solid-virtual": "3.13.37",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
@@ -174,7 +174,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
}
}
+5 -4
View File
@@ -212,6 +212,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
@@ -579,6 +580,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
@@ -618,9 +620,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
@@ -649,6 +649,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
@@ -672,7 +673,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
-1
View File
@@ -8,4 +8,3 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenAIResponses from "./openai-responses.js"
export * as OpenResponses from "./open-responses.js"
export * as OpenResponsesChannel from "./open-responses-channel.js"
export * as XAIResponses from "./xai-responses.js"
@@ -10,7 +10,6 @@ import {
} from "../route/transport/index.js"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
Schema.Record(Schema.String, Schema.Unknown),
@@ -26,6 +25,11 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}) => WebSocketChannelDriver
}
export interface Prepared {
@@ -154,13 +158,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: OpenResponsesContinuation.driver({
id: options.id,
name: options.name,
request: create.request,
message: create.message,
base,
}),
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
+13 -47
View File
@@ -42,12 +42,8 @@ const OpenResponsesInputImage = Schema.Struct({
const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
file_data: Schema.optional(Schema.String),
file_url: Schema.optional(Schema.String),
})
const OpenResponsesInputVideo = Schema.Struct({
type: Schema.tag("input_video"),
video_url: Schema.String,
file_data: Schema.String,
mime_type: Schema.optional(Schema.String),
})
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
@@ -58,7 +54,7 @@ const OpenResponsesOutputText = Schema.Struct({
text: Schema.String,
})
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const OpenResponsesReasoningSummaryText = Schema.Struct({
@@ -85,7 +81,6 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
OpenResponsesInputText,
OpenResponsesInputImage,
OpenResponsesInputFile,
OpenResponsesInputVideo,
])
const OpenResponsesFunctionCallOutput = Schema.Union([
@@ -328,6 +323,7 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -426,25 +422,18 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
: undefined
if (!media.mime.startsWith("image/")) {
if (target === "tool-result" && media.mime.startsWith("video/"))
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
...(url ? { file_url: url } : { file_data: media.base64 }),
file_data: media.dataUrl,
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerUserContent = Effect.fnUntraced(function* (
@@ -453,17 +442,10 @@ const lowerUserContent = Effect.fnUntraced(function* (
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
if (part.type === "media") return yield* lowerMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* (
@@ -476,20 +458,6 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
"tool-result",
)
})
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
)
})
@@ -542,7 +510,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
@@ -599,9 +567,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (itemID) hostedToolReferences.add(itemID)
@@ -1193,15 +1159,15 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase,
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
if (value === "commentary" || value === "final_answer") return value
return extension.messagePhase?.(value)
}
export const protocol = Protocol.make({
@@ -4,7 +4,7 @@ import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const PROTOCOL = "openai-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
@@ -161,4 +161,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export const OpenAIResponsesChannel = { driver } as const
+99 -23
View File
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -39,8 +40,20 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -54,6 +67,16 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const extension = {
id: ADAPTER,
name: NAME,
messagePhase: (value: unknown) => (value === null ? null : undefined),
lowerMedia: ({ part, media, request }) => {
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
return {
type: "input_file",
filename: part.filename ?? "document.pdf",
file_data: media.base64,
mime_type: media.mime,
}
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -105,7 +128,46 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
} satisfies OpenAIResponsesBody
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
type HostedToolData = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
readonly results?: unknown
readonly code?: string
readonly container_id?: string
readonly outputs?: unknown
readonly server_label?: string
readonly output?: unknown
readonly result?: string
readonly output_format?: "png" | "jpeg" | "webp"
readonly error?: unknown
}
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}) },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
} as const satisfies Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
type HostedToolType = keyof typeof HOSTED_TOOLS
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
@@ -126,22 +188,32 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
} as const satisfies ResponsesHostedTools.Definitions
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
state: OpenResponses.ParserState,
item: HostedToolItem,
) {
const tool = HOSTED_TOOLS[item.type]
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: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
@@ -152,8 +224,8 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
return OpenResponses.step(state, event)
}
@@ -175,7 +247,11 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
export const channelTransport = (options: Omit<Options, "driver">) =>
OpenResponsesChannel.transport<OpenAIResponsesBody>({
...options,
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
})
export const transport = channelTransport({
id: ADAPTER,
name: NAME,
@@ -1,70 +0,0 @@
import { Effect } from "effect"
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
readonly results?: unknown
readonly code?: string
readonly container_id?: string
readonly outputs?: unknown
readonly server_label?: string
readonly output?: unknown
readonly result?: string
readonly output_format?: "png" | "jpeg" | "webp"
readonly error?: unknown
}
export interface Definition {
readonly name: string
readonly input: (item: Item) => unknown
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
}
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
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
})
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
@@ -1,55 +0,0 @@
import { Effect } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const extension = {
id: ADAPTER,
name: NAME,
} satisfies OpenResponses.Extension
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
image_generation_call: { name: "image_generation", input: () => ({}) },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: OpenResponses.protocol.body,
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
})
export * as XAIResponses from "./xai-responses.js"
+3 -4
View File
@@ -5,8 +5,7 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
import { XAIResponses } from "../protocols/xai-responses.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { XAIImages } from "../protocols/xai-images.js"
import type { OpenAIOptionsInput } from "./openai-options.js"
import type { ProviderPackage } from "../provider-package.js"
@@ -35,9 +34,9 @@ const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenResponsesChannel.transport({
transport: OpenAIResponses.channelTransport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
@@ -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-07-22T18:15:36.438Z"
},
@@ -21,7 +14,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -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-07-22T18:15:34.867Z"
},
@@ -20,7 +14,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -1,17 +1,9 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:xai-responses",
"tool",
"tool-result"
],
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
"name": "pdf/xai-tool-result",
"recordedAt": "2026-07-22T18:15:43.608Z",
"protocol": "xai-responses"
"recordedAt": "2026-07-22T18:15:43.608Z"
},
"interactions": [
{
@@ -22,7 +14,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -1,16 +1,9 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:xai-responses",
"user-input"
],
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
"name": "pdf/xai-user-input",
"recordedAt": "2026-07-22T18:15:42.429Z",
"protocol": "xai-responses"
"recordedAt": "2026-07-22T18:15:42.429Z"
},
"interactions": [
{
@@ -21,7 +14,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
+26 -52
View File
@@ -848,7 +848,7 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({
id: "provider_call",
id: "tool_0",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
@@ -862,14 +862,14 @@ describe("Gemini route", () => {
Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
ToolCallPart.make({
id: "provider_call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "provider_call",
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
@@ -1101,17 +1101,21 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
})
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "weather" },
})
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-call",
id: response.toolCalls[0].id,
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1154,8 +1158,7 @@ describe("Gemini route", () => {
),
)
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
}),
)
@@ -1195,7 +1198,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -1209,20 +1212,16 @@ describe("Gemini route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
id: "call_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "call_0" } },
})
expect(response.toolCalls[1]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "news" },
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
@@ -1230,31 +1229,6 @@ describe("Gemini route", () => {
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
},
],
})
const req = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
}),
)
it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () {
const length = yield* LLMClient.generate(request).pipe(
@@ -93,7 +93,7 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
@@ -113,9 +113,7 @@ describe("Open Responses-compatible route", () => {
)
expect(prepared.body).toMatchObject({
input: [
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
],
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
@@ -28,7 +28,7 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
@@ -68,7 +68,7 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
return OpenAIResponsesChannel.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
@@ -1273,7 +1273,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
@@ -1300,12 +1300,12 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "report.pdf", file_data: base64 },
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
])
}),
)
it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1334,6 +1334,7 @@ describe("OpenAI Responses route", () => {
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
])
}),
@@ -1358,61 +1359,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
])
}),
)
it.effect("lowers remote tool-result media URLs without base64 wrapping", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [
{ type: "file", uri: "https://example.com/image.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/report.pdf", mime: "application/pdf", name: "report.pdf" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
])
}),
)
it.effect("lowers tool-result videos as input_video", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "record", input: {} })]),
Message.tool({
id: "call_1",
name: "record",
resultType: "content",
result: [
{ type: "file", uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" },
{ type: "file", uri: "https://example.com/demo.mp4", mime: "video/mp4" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_video", video_url: "data:video/mp4;base64,AAECAw==" },
{ type: "input_video", video_url: "https://example.com/demo.mp4" },
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
])
}),
)
@@ -2765,7 +2712,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
@@ -2773,7 +2720,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses standard inline file encoding for xAI user PDFs", () =>
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -2797,6 +2744,7 @@ describe("OpenAI Responses route", () => {
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
],
},
@@ -2821,7 +2769,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "file",
file_data: "AAECAw==",
file_data: "data:application/x-tar;base64,AAECAw==",
},
],
},
@@ -2829,37 +2777,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers remote user media URLs without base64 wrapping", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{
type: "media",
mediaType: "application/pdf",
data: "https://example.com/report.pdf",
filename: "report.pdf",
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
],
},
])
}),
)
it.effect("fails with a typed rate limit for provider error frames", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
id: "xai",
name: "xAI Grok 4.5",
provider: "xai",
protocol: "xai-responses",
protocol: "openai-responses",
requires: "XAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
@@ -1,77 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("extends the Open Responses baseline directly", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.protocol).toBe("xai-responses")
}),
)
it.effect("parses xAI reasoning text events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
type: "reasoning",
text: "Considering.",
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "x_search_1",
name: "x_search",
input: { query: "news" },
providerExecuted: true,
})
}),
)
})
@@ -259,18 +259,6 @@ export function event(
return makeEvent(type, data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
export function toolInputEnded(data: Extract<OpenCodeEvent, { type: "session.tool.input.ended" }>["data"]) {
return makeEvent("session.tool.input.ended", data)
}
export function toolCalled(data: Extract<OpenCodeEvent, { type: "session.tool.called" }>["data"]) {
return makeEvent("session.tool.called", data)
}
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -14,63 +13,10 @@ import {
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
type TimelineMessage,
} from "./fixture"
test("follows an expanded patch that arrives as the user reaches the bottom", async ({ page }) => {
const toolID = "prt_bottom_follow_patch"
const input = { patchText: "Update src/edit.ts" }
const timeline = await setupTimeline(page, {
messages: [
...history(20),
userMessage(),
assistantMessage([textPart("prt_bottom_follow_text", "Working")], { completed: false }),
],
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 300)
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 300 }))
element.scrollTop = element.scrollHeight
})
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await timeline.send(partUpdated(toolPart(toolID, "patch", "running", input)))
await timeline.send(
partUpdated(
toolPart(toolID, "patch", "completed", input, {
metadata: {
files: [
{
file: "src/edit.ts",
status: "modified",
patch: createTwoFilesPatch(
"a/src/edit.ts",
"b/src/edit.ts",
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index}\n`).join(""),
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index + 1}\n`).join(""),
),
additions: 40,
deletions: 40,
},
],
},
}),
),
)
await timeline.waitForPart(toolID)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_wheel_01_shell"
const followingID = "prt_wheel_02_following"
@@ -18,7 +18,7 @@ const branchDiffs = [
),
]
test("uses side placement by default and supports the terminal across the bottom", async ({ page }) => {
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
@@ -27,7 +27,7 @@ test("uses side placement by default and supports the terminal across the bottom
id: projectID,
worktree: directory,
vcs: "git",
name: "review-terminal-bottom",
name: "review-terminal-stacked",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
@@ -45,7 +45,7 @@ test("uses side placement by default and supports the terminal across the bottom
sessions: [
{
id: sessionID,
slug: "review-terminal-bottom",
slug: "review-terminal-stacked",
projectID,
directory,
title,
@@ -138,22 +138,7 @@ test("uses side placement by default and supports the terminal across the bottom
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectSideGeometry(page)
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
)
})
await page.reload()
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("#review-panel")).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectBottomGeometry(page)
await expectStackGeometry(page)
})
async function expectTree(page: Page, total: number, file: string) {
@@ -178,51 +163,23 @@ async function expectMountedTree(page: Page, total: number) {
expect(state.rows).toBeLessThanOrEqual(60)
}
async function expectSideGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
return {
reviewLeft: review.left,
reviewRight: review.right,
terminalLeft: terminal.left,
terminalRight: terminal.right,
terminalTop: terminal.top,
reviewTop: review.top,
}
})
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
}
async function expectBottomGeometry(page: Page) {
async function expectStackGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const terminalRect = terminal.getBoundingClientRect()
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
const sidebar = review.querySelector<HTMLElement>('[data-slot="session-review-v2-sidebar"]')!
return {
review: review.getBoundingClientRect().height,
reviewBottom: review.getBoundingClientRect().bottom,
reviewParent: reviewParent.height,
terminal: terminalRect.height,
terminalLeft: terminalRect.left,
terminalRight: terminalRect.right,
terminalTop: terminalRect.top,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
sidebar: sidebar.getBoundingClientRect().width,
viewport: window.innerWidth,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
expect(geometry.terminalLeft).toBeLessThanOrEqual(9)
expect(geometry.terminalRight).toBeGreaterThanOrEqual(geometry.viewport - 9)
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
}
@@ -1,48 +0,0 @@
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 }) => {
await page.addInitScript((server) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] }))
}, server)
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/api/event") {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/global/health" || url.pathname === "/api/health") {
return json(route, { healthy: true, version: "2.0.0" })
}
return json(route, {})
})
await page.goto("/")
await page.keyboard.press("Control+,")
const settings = page.locator(".settings-dialog")
await expect(settings).toBeVisible()
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
const username = editor.getByPlaceholder("username")
const password = editor.getByPlaceholder("password")
await username.click()
await expect(username).toBeFocused()
await username.fill("kit")
await expect(username).toHaveValue("kit")
await page.keyboard.press("Tab")
await expect(password).toBeFocused()
await password.fill("secret")
await expect(password).toHaveValue("secret")
})
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) })
}
@@ -99,7 +99,7 @@ test.describe("regression: session timeline local row state", () => {
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-scope="apply-patch"] button').click()
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push(...textEvents())
@@ -179,8 +179,8 @@ test.describe("regression: session timeline local row state", () => {
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
const trigger = wrapper.locator('[data-component="sticky-accordion-header"]')
const diff = wrapper.locator('[data-component="apply-patch-file-diff"]').first()
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const diff = wrapper.locator('[data-component="edit-content"]').first()
await expectAppVisible(diff)
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
const samples = await wrapper.evaluate(async (element) => {
@@ -190,8 +190,8 @@ test.describe("regression: session timeline local row state", () => {
for (const offset of [0, 120, 240, 360, 480]) {
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
await new Promise(requestAnimationFrame)
const trigger = element.querySelector<HTMLElement>('[data-component="sticky-accordion-header"]')!
const diff = element.querySelector<HTMLElement>('[data-component="apply-patch-file-diff"]')!
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
result.push({
offset,
trigger: trigger.getBoundingClientRect().y,
@@ -202,7 +202,7 @@ test.describe("regression: session timeline local row state", () => {
return result
})
expect(samples[0]!.trigger).toBeGreaterThanOrEqual(samples[0]!.diff)
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
})
@@ -234,9 +234,7 @@ async function readToolState(page: Page) {
.evaluate(
(element, textPartID) => ({
expanded: (() => {
const trigger =
element.querySelector('[data-scope="apply-patch"] button') ??
element.querySelector('[data-slot="collapsible-trigger"]')
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
@@ -411,9 +409,7 @@ function eventValue<Type extends OpenCodeEvent["type"]>(
}
function readExpanded(element: Element) {
const trigger =
element.querySelector('[data-scope="apply-patch"] button') ??
element.querySelector('[data-slot="collapsible-trigger"]')
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
@@ -1,12 +1,5 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
@@ -54,103 +47,5 @@ test("renders a completed single-file patch", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const file = wrapper.locator('[data-scope="apply-patch"]')
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("keeps an expanded file diff header at the same viewport position", async ({ page }) => {
const id = "prt_file_projection_anchored_patch"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
id,
"patch",
"completed",
{ patchText: "Update src/anchored.ts" },
{
metadata: {
files: [
{
file: "src/anchored.ts",
status: "modified",
patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after),
additions: 80,
deletions: 80,
},
],
},
},
),
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
]),
],
viewport: { width: 1200, height: 600 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const trigger = wrapper.getByRole("button")
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
})
await expect(trigger).toBeInViewport()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(0.5)
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
await trigger.dispatchEvent("pointerdown")
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.dispatchEvent("click")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
.toBeLessThanOrEqual(5)
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, 200)
await timeline.settle(40)
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
expect(scrolled).toBeGreaterThan(50)
expect(scrolled).toBeLessThan(400)
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await expect
.poll(() =>
scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
)
.toBeLessThanOrEqual(1)
await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
})
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
@@ -21,17 +21,15 @@ test("keeps patch file disclosures independent", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "false")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "true")
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
completedAssistantInfo,
messageUpdated,
@@ -9,13 +8,9 @@ import {
renderedPartID,
setupTimeline,
shell,
sessionID,
status,
stepStarted,
textPart,
toolCalled,
toolInputEnded,
toolInputStarted,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -39,56 +34,7 @@ for (const expanded of [false, true]) {
})
}
test("transitions a streaming shell from writing through command execution", async ({ page }) => {
const id = "prt_shell_streaming_input"
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const titleShimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(titleShimmer).toHaveAttribute("aria-label", "Shell")
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
const input = JSON.stringify({ command })
await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input }))
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText(command)
await expect(tool).not.toContainText("Writing command...")
await timeline.send(
toolCalled({
sessionID,
assistantMessageID: assistantID,
id,
input: { command },
executed: true,
}),
)
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText(command)
})
test("shimmers and expands a running shell command", async ({ page }) => {
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
await setupTimeline(page, {
@@ -98,10 +44,8 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -73,81 +73,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [toolPart("call_subagent", "subagent", "streaming", {})],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute(
"aria-label",
"Delegating agent...",
)
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "13px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "13px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
@@ -156,24 +81,7 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
.toEqual({ aligned: true, ordered: true })
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
@@ -196,10 +104,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
@@ -210,14 +118,7 @@ test("shows a badge for active background work", async ({ page }) => {
sessionStatus: { [childID]: { type: "busy" } },
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
).toBeVisible()
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
@@ -292,15 +193,10 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const dock = page.locator('[data-component="session-background-dock"]')
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,7 +1,6 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
status,
toolPart,
@@ -70,126 +69,9 @@ test.describe("session timeline projection", () => {
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
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(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"patch",
"completed",
{ patchText: "Update src/first.ts" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
})
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.patchRow = "stable"
})
await timeline.send(
partUpdated(toolPart(second, "patch", "running", { patchText: "Update more files" }, { metadata: {} })),
)
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable")
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute(
"aria-expanded",
"true",
)
await timeline.send(
partUpdated(
toolPart(
second,
"patch",
"completed",
{ patchText: "Update more files" },
{
metadata: {
files: [patchFile("src/first.ts", "modified"), patchFile("src/second.ts", "added")],
},
},
),
),
)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(group.locator('[data-scope="apply-patch"] [data-type="add"] button')).toHaveAttribute(
"aria-expanded",
"false",
)
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
const first = "prt_edit_first"
const second = "prt_edit_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
toolPart(
second,
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
settings: { editToolPartsExpanded: true },
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -314,7 +196,11 @@ function patchPart(id: string) {
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
},
},
)
@@ -127,16 +127,19 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
],
})
for (const [id, name] of [
[pending, "sample-skill"],
[completed, "OpenCode"],
] as const) {
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
for (const id of [pending, completed]) {
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.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)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible()
}
})
@@ -9,12 +9,10 @@ const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const ptyInput: string[] = []
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
ptyInput.length = 0
await mockOpenCodeServer(page, {
directory,
project: {
@@ -72,22 +70,7 @@ test.beforeEach(async ({ page }) => {
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}),
)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
ws.onMessage((message) => ptyInput.push(message.toString()))
})
})
test("clears the terminal line with Command+Delete", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.keyboard.press("Meta+Backspace")
await expect.poll(() => ptyInput.join("")).toBe("\x15")
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
-1
View File
@@ -48,7 +48,6 @@
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
-11
View File
@@ -15,14 +15,3 @@
/*.css
Content-Type: text/css
/site.webmanifest
Content-Type: application/manifest+json
/sw.js
Content-Type: application/javascript
Cache-Control: no-cache
/registerSW.js
Content-Type: application/javascript
Cache-Control: no-cache
+1 -3
View File
@@ -61,9 +61,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
})
}
if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
// Lazy chunks can import the entry chunk back under a distinct URL, so claim the root before async startup.
root.dataset.opencodeMounted = ""
if (root instanceof HTMLElement) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
+2 -3
View File
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -50,8 +50,7 @@ export function createHomeController() {
selection: {
value: selection,
set: setSelection,
focusServer: (conn: ServerConnection.Any) =>
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
},
server: {
list: global.servers.list,
+3 -1
View File
@@ -1,5 +1,6 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSearchParams } from "@solidjs/router"
@@ -14,6 +15,7 @@ import { useNewSessionCommands } from "./commands"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
@@ -67,7 +69,7 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
+14 -9
View File
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import type { ComposerModel } from "@/composer/model"
@@ -14,7 +15,6 @@ import {
type PromptProjectController,
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -87,16 +87,21 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { visible: boolean }) {
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
const language = useLanguage()
return (
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
<Show when={props.mount} keyed>
{(mount) => (
<Portal mount={mount}>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</Portal>
)}
</Show>
)
}
+1 -14
View File
@@ -30,7 +30,6 @@ export const dict = {
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.project.index": "Switch to project {{index}}",
"command.project.copyID": "Copy Project ID",
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
@@ -94,7 +93,6 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
@@ -546,6 +544,7 @@ export const dict = {
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
"toast.session.unshare.success.title": "Session unshared",
"toast.session.unshare.success.description": "Session unshared successfully!",
"toast.session.unshare.failed.title": "Failed to unshare session",
@@ -555,10 +554,6 @@ export const dict = {
"toast.session.export.success.description": "Saved session to {{filename}}",
"toast.session.export.failed.title": "Failed to export session",
"toast.session.export.failed.description": "An error occurred while exporting the session",
"toast.session.copyID.failed.title": "Failed to copy session ID",
"toast.session.copyID.failed.description": "An error occurred while copying the session ID",
"toast.project.copyID.failed.title": "Failed to copy project ID",
"toast.project.copyID.failed.description": "An error occurred while copying the project ID",
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
@@ -662,10 +657,6 @@ export const dict = {
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -922,10 +913,6 @@ export const dict = {
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
"settings.general.row.shell.autoDefault": "Auto (Default)",
"settings.general.row.shell.terminalOnly": "terminal only",
"settings.general.row.terminalPlacement.title": "Terminal placement",
"settings.general.row.terminalPlacement.description": "Choose where the terminal opens in sessions",
"settings.general.row.terminalPlacement.side": "Side",
"settings.general.row.terminalPlacement.bottom": "Bottom",
"settings.general.row.appearance.title": "Appearance",
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
"settings.general.row.colorScheme.title": "Color scheme",
@@ -89,7 +89,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
const projectCommand = withCategory(language.t("command.category.project"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
@@ -127,46 +126,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
}
const copySessionID = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
try {
await navigator.clipboard.writeText(sessionID)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: sessionID,
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.copyID.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.copyID.failed.description"),
})
}
}
const copyProjectID = async () => {
const projectID = actions.session.data.info()?.projectID
if (!projectID) return
try {
await navigator.clipboard.writeText(projectID)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: projectID,
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.project.copyID.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.project.copyID.failed.description"),
})
}
}
const openFile = () => {
void openDialog(
() => import("@/shell/commands/dialog"),
@@ -312,12 +271,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !actions.session.identity.params.id,
onSelect: exportSession,
}),
sessionCommand({
id: "session.copyID",
title: language.t("command.session.copyID"),
disabled: !actions.session.identity.params.id,
onSelect: copySessionID,
}),
]
const fileCmds = () => {
@@ -341,15 +294,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
].filter((v) => !!v)
}
const projectCmds = () => [
projectCommand({
id: "project.copyID",
title: language.t("command.project.copyID"),
disabled: !actions.session.data.info()?.projectID,
onSelect: copyProjectID,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
@@ -463,7 +407,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
command.register("session", () => [
...sessionCmds(),
...projectCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
+1 -1
View File
@@ -133,7 +133,7 @@ export function createActiveSessionRegion(input: {
const scroller = input.timeline.scroller()
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
input.timeline.view.markUserScroll(scroller)
input.timeline.view.markGesture(scroller)
return
}
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
@@ -2,12 +2,15 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionBackgroundDock } from "@/session/requests/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
> & {
background: Pick<SessionComposerRegionController["state"]["background"], "blocking" | "tasks" | "move">
}
export type SessionComposerRegionViewController = Pick<
SessionComposerRegionController,
@@ -29,6 +32,9 @@ export function SessionComposerRegion(props: {
}) {
const language = useLanguage()
const controller = props.controller
const background = () =>
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
return (
<div
ref={controller.setDockRef}
@@ -75,10 +81,22 @@ export function SessionComposerRegion(props: {
</>
}
>
<Show when={background()}>
<div>
<SessionBackgroundDock
blocking={controller.state.background.blocking()}
tasks={controller.state.background.tasks()}
onBackground={() => void controller.state.background.move()}
/>
</div>
</Show>
<div
classList={{
"relative z-[70]": true,
}}
style={{
"margin-top": `${background() ? -36 : 0}px`,
}}
>
<Show
when={controller.child()}
@@ -1,12 +1,13 @@
import { createMemo } from "solid-js"
import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
@@ -27,9 +28,15 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const rightMount = useTitlebarRightMount()
return (
<TitlebarRight>
<SessionHeaderActions state={actions()} />
</TitlebarRight>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderActions state={actions()} />
</Portal>
)}
</Show>
)
}
@@ -63,7 +63,6 @@ export function createSessionRequestModel() {
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
@@ -94,13 +93,11 @@ export function createSessionRequestModel() {
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
const agent = part.state.input.agent
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
},
]
})
@@ -0,0 +1,83 @@
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import { Keybind } from "@opencode-ai/ui/keybind"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionBackgroundPullout } from "./session-background-pullout"
export function SessionBackgroundDock(props: {
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
tasks: { id: string; type: "shell" | "subagent"; label: string }[]
onBackground: () => void
}) {
const language = useLanguage()
const command = useCommand()
const [store, setStore] = createStore({ collapsed: true })
const describe = (shells: number, subagents: number) => {
const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined
const subagent = subagents
? language.plural("session.background.subagent", subagents, { count: subagents })
: undefined
if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent })
return shell ?? subagent ?? ""
}
const summary = createMemo(() => {
const shells = props.tasks.filter((task) => task.type === "shell").length
return describe(shells, props.tasks.length - shells)
})
const moving = createMemo(() => {
const shells = props.blocking.filter((task) => task.type === "shell").length
const subagents = props.blocking.length - shells
const tasks = describe(shells, subagents)
return tasks ? language.t("session.background.moveTasks", { tasks }) : ""
})
const background = createMemo(() =>
summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "",
)
const blocking = () => props.blocking.length > 0
const toggle = () => {
if (blocking()) {
props.onBackground()
return
}
setStore("collapsed", (value) => !value)
}
return (
<SessionBackgroundPullout
label={
<span class="flex flex-col items-start">
{blocking() && (
<span>
<span class="text-v2-text-text-muted">{moving()}</span>
<span class="pl-2">
<Keybind keys={command.keybindParts("session.background")} variant="neutral" />
</span>
</span>
)}
{!!props.tasks.length && <span class="text-v2-text-text-faint">{background()}</span>}
</span>
}
ariaLabel={[moving(), background()].filter(Boolean).join(". ")}
multiline={blocking() && props.tasks.length > 0}
collapsed={blocking() || store.collapsed}
collapsible={!blocking()}
onToggle={toggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
>
<div class="px-4 pb-11 flex flex-col gap-1.5">
<For each={props.tasks}>
{(task) => (
<div class="flex min-w-0 items-baseline gap-2 text-13-regular">
<span class="shrink-0 text-13-medium text-text-strong">
{language.t(task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default")}
</span>
<span class="truncate text-text-weak">{task.label}</span>
</div>
)}
</For>
</div>
</SessionBackgroundPullout>
)
}
@@ -0,0 +1,114 @@
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, createMemo, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
export function SessionBackgroundPullout(props: {
label: JSX.Element
ariaLabel: string
multiline?: boolean
collapsed: boolean
collapsible?: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
children: JSX.Element
}) {
const [store, setStore] = createStore({ height: 78, header: 42 })
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const off = createMemo(() => value() > 0.98)
const base = createMemo(() => Math.max(78, store.header + 36))
const full = createMemo(() => Math.max(base(), store.height))
let contentRef: HTMLDivElement | undefined
let headerRef: HTMLDivElement | undefined
createEffect(() => {
const element = contentRef
const header = headerRef
if (!element || !header) return
const update = () => {
setStore("height", (height) => Math.max(height, element.scrollHeight))
setStore("header", header.getBoundingClientRect().height)
}
update()
createResizeObserver([element, header], update)
})
return (
<div
data-component="session-background-dock"
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(base(), full() - value() * (full() - base()))}px`,
}}
>
<div ref={contentRef}>
<div
ref={headerRef}
data-action="session-background-toggle"
class="flex items-center gap-2 overflow-visible pl-4 pr-2"
classList={{
"h-[42px]": !props.multiline,
"min-h-[42px] py-2": props.multiline,
}}
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
class="cursor-default inline-flex items-baseline shrink-0 overflow-visible font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
aria-label={props.ariaLabel}
style={{
"--tool-motion-odometer-ms": "600ms",
"--tool-motion-mask": "18%",
"--tool-motion-mask-height": "0px",
"--tool-motion-spring-ms": "560ms",
"white-space": "pre",
}}
>
{props.label}
</span>
{props.collapsible !== false && (
<div class="ml-auto">
<IconButton
data-action="session-background-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
icon={<Icon name="chevron-down" />}
size="normal"
variant="ghost"
style={{ transform: `rotate(${value() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
)}
</div>
<div
data-slot="session-background-list"
aria-hidden={props.collapsed || off()}
classList={{ "pointer-events-none": value() > 0.1 }}
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - value())}` }}
>
{props.children}
</div>
</div>
</div>
)
}
+5 -9
View File
@@ -15,10 +15,6 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen())
const sideTerminalOpen = createMemo(() => desktopTerminalOpen() && settings.general.terminalPlacement() === "side")
const bottomTerminalOpen = createMemo(
() => desktopTerminalOpen() && settings.general.terminalPlacement() === "bottom",
)
const fileTreeOpen = createMemo(
() =>
session.isDesktop() &&
@@ -27,7 +23,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
opened: layout.fileTree.opened(),
}),
)
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
const resizable = createMemo(() => reviewPanelOpen() || desktopTerminalOpen())
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
const [rowWidth, setRowWidth] = createSignal<number>()
let row: HTMLDivElement | undefined
@@ -61,7 +57,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
const panelLayout = createMemo(() =>
sessionPanelLayout({
review: reviewPanelOpen(),
terminal: sideTerminalOpen(),
terminal: desktopTerminalOpen(),
files: fileTreeOpen(),
}),
)
@@ -101,11 +97,11 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
panelOpen: reviewPanelOpen,
snap: reviewSnap,
},
side: { layout: panelLayout },
side: { layout: panelLayout, open: sidePanelOpen },
size,
terminal: {
bottomOpen: bottomTerminalOpen,
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
desktopOpen: desktopTerminalOpen,
inlineOnlyOpen: createMemo(() => desktopTerminalOpen() && !reviewPanelOpen()),
open: terminalOpen,
},
}
+56 -83
View File
@@ -72,24 +72,25 @@ export function SessionScreen(props: { session: SessionModel }) {
{(_id) => (
<MessageTimeline
session={session}
background={composer.region.state.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onAutoScrollHandleScroll={timeline.autoScroll.handleScroll}
onMarkScrollGesture={timeline.view.markGesture}
hasScrollGesture={timeline.view.hasGesture()}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
onAutoScrollInteraction={timeline.autoScroll.handleInteraction}
shouldAnchorBottom={timeline.view.shouldAnchorBottom()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
setHistoryAnchor={timeline.view.setHistoryAnchor}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
@@ -119,88 +120,51 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<SessionRouteFrame>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
}}
style={{
width: screen.panel.width(),
}}
>
<Show when={screen.panel.key()} keyed>
{(_) => (
<SessionPanelFrame raised={!!session.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2 p-2">
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
}}
style={{
width: screen.panel.width(),
}}
>
<Show when={screen.panel.key()} keyed>
{(_) => (
<SessionPanelFrame raised={!!session.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
<Show when={screen.panel.resizable()}>
<div onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="-end-1"
direction="horizontal"
size={screen.panel.resizedWidth()}
min={SESSION_PANEL_WIDTH_MIN}
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={isDesktop() && screen.side.layout().visible}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={screen.review.panelOpen() || screen.files.open()}>
<div class="min-h-0 flex-1">
<SessionDesktopReview review={review} />
</div>
</Show>
<Show when={screen.side.layout().stacked}>
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<Show when={screen.terminal.open() && !screen.terminal.bottomOpen()}>
<div
classList={{
"min-h-0 shrink-0": screen.side.layout().stacked,
"min-h-0 flex-1": !screen.side.layout().stacked,
}}
>
<TerminalPanel stacked={screen.side.layout().stacked} />
</div>
</Show>
<Show when={screen.panel.resizable()}>
<div onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="-end-1"
direction="horizontal"
size={screen.panel.resizedWidth()}
min={SESSION_PANEL_WIDTH_MIN}
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={screen.terminal.open() && (!isDesktop() || screen.terminal.bottomOpen())}>
<div classList={{ "relative min-h-0 shrink-0": isDesktop() }}>
<Show when={isDesktop()}>
<div
class="absolute z-10 -top-1 left-0 right-0 h-2"
onPointerDown={() => screen.size.start()}
>
<Show when={isDesktop() ? screen.side.layout().visible : screen.terminal.open()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop() && (screen.review.panelOpen() || screen.files.open())}>
<div class="min-h-0 flex-1">
<SessionDesktopReview review={review} />
</div>
</Show>
<Show when={screen.side.layout().stacked}>
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
@@ -216,7 +180,16 @@ export function SessionScreen(props: { session: SessionModel }) {
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} />
<Show when={screen.terminal.open()}>
<div
classList={{
"min-h-0 shrink-0": screen.side.layout().stacked,
"min-h-0 flex-1": !screen.side.layout().stacked,
}}
>
<TerminalPanel stacked={screen.side.layout().stacked} />
</div>
</Show>
</div>
</Show>
</div>
@@ -1,23 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
let syncs = 0
const sessions = {
get: () => undefined,
sync: () => {
syncs++
return Promise.resolve()
},
}
const session = createSessionResolution(() => undefined, () => sessions)
expect(session()).toBeUndefined()
expect(syncs).toBe(0)
dispose()
})
})
})
@@ -29,20 +29,15 @@ type Resolution<T> = { id: string; store: SessionStore<T> } & (
// session that simply has not resolved yet. Resolve failures rethrow on read so
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
export function createSessionResolution<T>(
sessionID: () => string | undefined,
sessionID: () => string,
sessions: () => SessionStore<T>,
options?: { children?: boolean },
) {
const cached = createMemo(() => {
const id = sessionID()
if (!id) return
return sessions().get(id)
})
const cached = createMemo(() => sessions().get(sessionID()))
const [status, setStatus] = createSignal<Resolution<T>>()
createEffect(
on([sessionID, sessions] as const, ([id, store]) => {
if (!id) return
let stale = false
onCleanup(() => {
stale = true
@@ -65,11 +60,10 @@ export function createSessionResolution<T>(
return createMemo(() => {
const id = sessionID()
if (!id) return
const value = cached()
if (value) return value
const state = status()
if (!state || state.id !== id || state.store !== sessions()) return undefined
if (state?.id !== id || state.store !== sessions()) return undefined
if (state.state === "failed") throw state.failure
// A session missing after settlement was deleted, possibly by another client.
// Match the resolve error so the boundary shows the
+8
View File
@@ -84,6 +84,7 @@ export type SessionPreviewProps = {
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
}
@@ -195,6 +196,13 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
setState("request", undefined)
setState("activity", `Permission response: ${response}`)
},
background: {
blocking: () => [],
tasks: () => props.backgroundTasks ?? [],
move: async () => {
setState("activity", "Requested background execution")
},
},
blocked: () => state.request !== undefined,
},
centered: () => true,
@@ -1,17 +0,0 @@
import { describe, expect, test } from "bun:test"
import { terminalKeyInput } from "./terminal-key-event"
describe("terminalKeyInput", () => {
test("maps Command+Delete to the terminal line-clear control code", () => {
const event = new KeyboardEvent("keydown", { key: "Backspace", metaKey: true })
expect(terminalKeyInput(event)).toBe("\x15")
})
test("leaves other Backspace shortcuts to the terminal", () => {
expect(terminalKeyInput(new KeyboardEvent("keydown", { key: "Backspace" }))).toBeUndefined()
expect(
terminalKeyInput(new KeyboardEvent("keydown", { key: "Backspace", metaKey: true, shiftKey: true })),
).toBeUndefined()
})
})
@@ -1,5 +0,0 @@
export function terminalKeyInput(event: KeyboardEvent) {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "backspace") return
return "\x15"
}
@@ -15,7 +15,6 @@ import { useServerSDK } from "@/runtime/server/client"
import { terminalFontFamily, useSettings } from "@/settings/model"
import type { LocalPTY } from "@/session/terminal/context"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/session/terminal/runtime-adapters"
import { terminalKeyInput } from "@/session/terminal/terminal-key-event"
import { terminalWriter } from "@/session/terminal/writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
@@ -409,12 +408,6 @@ export const Terminal = (props: TerminalProps) => {
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
const input = terminalKeyInput(event)
if (input) {
t.input(input, true)
return true
}
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
document.execCommand("copy")
return true
@@ -1,36 +0,0 @@
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
{ id: "task_status", type: "shell" as const, label: "opencode2 service status" },
{ id: "task_openapi", type: "shell" as const, label: "opencode2 api get /openapi.json" },
{ id: "task_tests", type: "shell" as const, label: "bun test packages/app" },
]
export default {
title: "OpenCode/Session/Background work",
id: "session-background-work",
parameters: {
docs: {
description: {
component: "Production controls for moving blocking work and inspecting active background tasks.",
},
},
},
}
export const InlineMoveHint = {
render: () => (
<div class="flex w-[696px] max-w-full flex-col items-start gap-4">
<BackgroundMoveHint keybind={["Ctrl", "B"]} />
</div>
),
}
export const SummaryPanelEntry = {
render: () => (
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<BackgroundWorkSummary tasks={tasks} />
</div>
),
}
@@ -1,7 +1,8 @@
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocation } from "@solidjs/router"
import { createEffect, createSignal, on, onCleanup } from "solid-js"
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLayout } from "@/shell/state/layout"
import type { SessionModel } from "../model"
@@ -15,6 +16,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
const [state, setState] = createStore({
messageID: undefined as string | undefined,
pendingMessage: undefined as string | undefined,
gestureAt: 0,
scroll: {
overflow: false,
jump: false,
@@ -24,18 +26,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
dock: undefined as HTMLDivElement | undefined,
},
})
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
// it from scroll geometry; everything else only expresses explicit intent.
const [pinned, setPinned] = createSignal(true)
const pin = () => setPinned(true)
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setPinned(false)
}
const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "none" })
let scroller: HTMLDivElement | undefined
let dockHeight = 0
let revealMessage = (_id: string) => {}
let scrollToEnd = () => {}
let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {}
let scrollMark = 0
let messageMark = 0
let scrollStateFrame: number | undefined
@@ -105,10 +102,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pendingMessage: () => state.pendingMessage,
setPendingMessage: (value) => setState("pendingMessage", value),
setActiveMessage,
follow: {
unpin,
toBottom: () => {
pin()
autoScroll: {
pause: autoScroll.pause,
forceScrollToBottom: () => {
autoScroll.resume()
scrollToEnd()
},
},
@@ -120,7 +117,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
})
const resume = () => {
setState("messageID", undefined)
pin()
autoScroll.resume()
scrollToEnd()
clearMessageHash()
if (scroller) scheduleScrollState(scroller)
@@ -136,23 +133,21 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resume()
return
}
unpin()
autoScroll.pause()
scrollToMessage(messages[target], "auto")
}
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
const markUserScroll = (target?: EventTarget | null) => {
const shouldAnchorBottom = () =>
!location.hash && !state.messageID && !state.pendingMessage && !autoScroll.userScrolled()
const markGesture = (target?: EventTarget | null) => {
if (!scroller) return
const element = target instanceof Element ? target : undefined
const nested = element?.closest("[data-scrollable]")
if (nested && nested !== scroller) return
scrollMark += 1
}
const selectionInteraction = () => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) unpin()
setState("gestureAt", Date.now())
}
const setScrollRef = (element: HTMLDivElement | undefined) => {
scroller = element
autoScroll.scrollRef(element)
if (!element) return
scheduleScrollState(element)
fill()
@@ -163,12 +158,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
historyRequests.add(owner.key)
const before = timeline.messages().length
try {
await timeline.history.loadOlder()
await timeline.history.loadOlder({
before: () => owner.run(captureHistoryAnchor),
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
})
} finally {
historyRequests.delete(owner.key)
}
if (!owner.current() || timeline.messages().length <= before) return
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
@@ -179,7 +177,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (
historyRequests.has(session.ownership.key()) ||
timeline.history.loading() ||
pinned() ||
!autoScroll.userScrolled() ||
!scroller ||
scroller.scrollTop >= 200
)
@@ -191,7 +189,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!session.identity.params.id || !timeline.ready()) return
if (!pinned() || timeline.history.loading() || !scroller) return
if (autoScroll.userScrolled() || timeline.history.loading() || !scroller) return
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
void loadOlder()
})
@@ -220,16 +218,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
() => session.identity.params.id,
(id, previous) => {
if (!id || !previous || id === previous || state.messageID || state.pendingMessage || location.hash) return
pin()
scrollToEnd()
autoScroll.resume()
},
),
)
createEffect(
on(
pinned,
(value) => {
if (!value) return
autoScroll.userScrolled,
(scrolled) => {
if (scrolled) return
setState("messageID", undefined)
clearMessageHash()
},
@@ -244,11 +241,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
timeline.ready(),
timeline.history.more(),
timeline.history.loading(),
pinned(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, more, loading, following]) => {
if (id && ready && more && !loading && following) fill()
([id, ready, more, loading, scrolled]) => {
if (id && ready && more && !loading && !scrolled) fill()
},
{ defer: true },
),
@@ -267,7 +264,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (next === dockHeight) return
const delta = next - dockHeight
const stick = scroller
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
? !autoScroll.userScrolled() ||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next
if (stick) scrollToEnd()
@@ -287,6 +285,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resume,
setActiveMessage,
},
autoScroll,
lastUserMessage: timeline.lastUserMessage,
resource: timeline.resource,
ready: timeline.ready,
@@ -294,19 +293,25 @@ export function createSessionTimelineInteraction(session: SessionModel) {
scroller: () => scroller,
view: {
anchor,
markUserScroll,
hasGesture: () => Date.now() - state.gestureAt < 250,
markGesture,
markUserScroll: () => {
scrollMark += 1
},
onHistoryScroll,
pin,
pinned,
selectionInteraction,
scheduleScrollState,
setContentRef: (element: HTMLDivElement | undefined) => {
setState("refs", "content", element)
autoScroll.contentRef(element)
if (scroller) scheduleScrollState(scroller)
},
setDockRef: (element: HTMLDivElement | undefined) => {
setState("refs", "dock", element)
},
setHistoryAnchor: (handlers: { capture: () => void; restore: (done: boolean) => void }) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
},
setRevealMessage: (reveal: (id: string) => void) => {
revealMessage = reveal
},
@@ -314,7 +319,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setScrollToEnd: (scroll: () => void) => {
scrollToEnd = scroll
},
unpin,
shouldAnchorBottom,
},
}
}
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { scheduleConnectedMeasure } from "./measure"
test("does not measure an element detached before the frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
element.remove()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(0)
})
test("measures a connected element on the next frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(1)
element.remove()
})
@@ -0,0 +1,5 @@
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
return requestAnimationFrame(() => {
if (element.isConnected) measure(element)
})
}
@@ -1,15 +1,11 @@
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor } from "solid-js"
import createPresence from "solid-presence"
import { createEffect, createMemo, createSignal, on, Show, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
@@ -19,7 +15,7 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
@@ -28,98 +24,6 @@ import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
tasks: Accessor<BackgroundTask[]>
move: () => Promise<void>
}
export function BackgroundMoveHint(props: { keybind?: string[] }) {
const language = useLanguage()
const command = useCommand()
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
const keys = () => props.keybind ?? command.keybindParts("session.background")
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
return (
<div
data-component="session-background-hint"
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
>
<span data-slot="session-background-hint-prefix" class="shrink-0">
{parts()[0].trim()}
</span>
<Keybind keys={keys()} variant="neutral" />
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
</div>
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={open()}
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={setOpen}
>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
>
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
{props.tasks.length}
</Badge>
<TextShimmer
as="span"
text={language.t("session.background.running")}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<div
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</div>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
@@ -190,7 +94,6 @@ function SessionSummaryPanel(props: {
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
@@ -265,9 +168,6 @@ function SessionSummaryPanel(props: {
)}
</Show>
</button>
<Show when={props.backgroundTasks.length > 0}>
<BackgroundWorkSummary tasks={props.backgroundTasks} />
</Show>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
@@ -286,18 +186,18 @@ function SessionSummaryPanel(props: {
type MessageTimelineProps = {
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
scroll: { overflow: boolean; jump: boolean }
onResumeScroll: () => void
setScrollRef: (el: HTMLDivElement | undefined) => void
onScheduleScrollState: (el: HTMLDivElement) => void
onPin: () => void
onUnpin: () => void
onUserScroll: (target?: EventTarget | null) => void
onAutoScrollHandleScroll: () => void
onMarkScrollGesture: (target?: EventTarget | null) => void
hasScrollGesture: boolean
onUserScroll: () => void
onHistoryScroll: () => void
onSelectionInteraction: (event: MouseEvent) => void
pinned: boolean
onAutoScrollInteraction: (event: MouseEvent) => void
shouldAnchorBottom: boolean
centered: boolean
setContentRef: (el: HTMLDivElement) => void
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
@@ -307,6 +207,7 @@ type MessageTimelineProps = {
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
export function MessageTimeline(props: MessageTimelineProps) {
@@ -363,25 +264,28 @@ function MessageTimelineView(
)
const turnPadding = () => "px-4 md:px-5"
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
const pinned = createMemo(() => props.pinned)
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: props.data.sessionKey,
projection,
showHeader,
pinned,
shouldAnchorBottom,
hasScrollGesture,
scroll: () => props.scroll,
onResumeScroll: props.onResumeScroll,
setScrollRef: props.setScrollRef,
setContentRef: props.setContentRef,
onScheduleScrollState: props.onScheduleScrollState,
onPin: props.onPin,
onUnpin: props.onUnpin,
onSelectionInteraction: props.onSelectionInteraction,
onAutoScrollHandleScroll: props.onAutoScrollHandleScroll,
onAutoScrollInteraction: props.onAutoScrollInteraction,
onMarkScrollGesture: props.onMarkScrollGesture,
onUserScroll: props.onUserScroll,
onHistoryScroll: props.onHistoryScroll,
setRevealMessage: props.setRevealMessage,
setScrollToEnd: props.setScrollToEnd,
setHistoryAnchor: props.setHistoryAnchor,
})
const VirtualizedTimeline = virtualized.View
const [title, setTitle] = createStore({
@@ -446,59 +350,14 @@ function MessageTimelineView(
padding: turnPadding,
anchor: props.anchor,
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
const row = projection
.rows()
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
)
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
(previous) => {
const show = backgroundHintPartID() !== undefined
return { show, animate: previous.animate || previous.show !== show }
},
{ show: backgroundHintPartID() !== undefined, animate: false },
)
const backgroundHintPresence = createPresence({
show: () => backgroundHintVisibility().show,
element: () => backgroundHintRef() ?? null,
})
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex h-8 items-start pt-2 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
"animate-out fade-out fill-mode-forwards":
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
}}
>
<BackgroundMoveHint />
</div>
</div>
</Show>
}
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
return content?.type === "tool" && ["edit", "write"].includes(content.name)
return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name)
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
@@ -626,7 +485,6 @@ function MessageTimelineView(
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
@@ -3,7 +3,6 @@ import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
instance: Virtualizer<TScrollElement, TItemElement>,
callback: (offset: number, isScrolling: boolean) => void,
onReconnect?: () => void,
) {
let active = true
const deliver = (offset: number, isScrolling: boolean) => {
@@ -55,7 +54,6 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
}
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
removed = false
onReconnect?.()
startCheck()
})
})
+207 -105
View File
@@ -1,6 +1,7 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/session/message-gesture"
import { useLanguage } from "@/runtime/i18n/language"
import {
createEffect,
@@ -16,14 +17,11 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import type { createTimelineProjection } from "./projection"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
type Projection = Pick<
@@ -35,25 +33,25 @@ type Input = {
sessionKey: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
/** True while the timeline follows the newest content. Drives every anchoring decision. */
pinned: Accessor<boolean>
shouldAnchorBottom: Accessor<boolean>
hasScrollGesture: Accessor<boolean>
scroll: Accessor<{ overflow: boolean; jump: boolean }>
onResumeScroll: () => void
setScrollRef: (element: HTMLDivElement | undefined) => void
setContentRef: (element: HTMLDivElement) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onPin: () => void
onUnpin: () => void
onSelectionInteraction: (event: MouseEvent) => void
onUserScroll: (target?: EventTarget | null) => void
onAutoScrollHandleScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onMarkScrollGesture: (target?: EventTarget | null) => void
onUserScroll: () => void
onHistoryScroll: () => void
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
type ViewProps = {
header: JSX.Element
bottomSpacer?: JSX.Element
workspaceSession: Accessor<boolean>
deferred: (row: TimelineRow.TimelineRow) => boolean
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
@@ -64,31 +62,88 @@ export function createTimelineVirtualizer(input: Input) {
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const knownKeys = new Set(rows().map(TimelineRow.key))
const addedKeys = new Set<string>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
let touchGesture: number | undefined
let prependAnchor: { key: string; offset: number } | undefined
let prependAnchorFrame: number | undefined
let prependLoading = false
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
const clearPrependAnchor = () => {
prependLoading = false
prependAnchor = undefined
if (prependAnchorFrame === undefined) return
cancelAnimationFrame(prependAnchorFrame)
prependAnchorFrame = undefined
}
const capturePrependAnchor = () => {
prependLoading = true
updatePrependAnchor()
}
const updatePrependAnchor = () => {
const root = listRoot()
if (!root) return
const view = root.getBoundingClientRect()
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return
if (!anchor.element.dataset.timelineKey) return
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
}
const restorePrependAnchor = (done: boolean) => {
if (done) prependLoading = false
applyPrependAnchor()
}
const applyPrependAnchor = () => {
const root = listRoot()
if (!root || !prependAnchor) return
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
let frames = 0
let stable = 0
const apply = () => {
prependAnchorFrame = undefined
const anchor = prependAnchor
if (!anchor) return
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
const delta = element
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
: undefined
if (delta !== undefined && Math.abs(delta) > 0.5) {
root.scrollTop += delta
stable = 0
} else {
stable += 1
}
frames += 1
if (stable >= 30 || frames >= 180) {
if (!prependLoading) prependAnchor = undefined
return
}
prependAnchorFrame = requestAnimationFrame(apply)
}
prependAnchorFrame = requestAnimationFrame(apply)
}
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) =>
observeElementOffsetReconnectAware(instance, callback, () => {
if (input.pinned()) virtualizer.scrollToEnd()
}),
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
scrollToFn: (offset, options, instance) => {
@@ -97,62 +152,67 @@ export function createTimelineVirtualizer(input: Input) {
},
get getItemKey() {
const items = rows()
items
.map(TimelineRow.key)
.filter((key) => !knownKeys.has(key))
.forEach((key) => {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => {
const row = items[index]
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
get anchorTo() {
return input.pinned() ? "end" : "start"
},
get followOnAppend() {
return input.pinned()
},
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 80,
get scrollMargin() {
return input.showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
rangeExtractor: (range) => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
// reconciles the end. Coalesce one correction per measurement batch, before paint.
const anchorResizedBottom = () => {
if (resizeAnchorScheduled) return
if (resizeAnchorScheduled || input.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!input.pinned()) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
resizeItem(index, size)
if (listRoot() && input.pinned()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) {
return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
const root = listRoot()
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
const view = root.getBoundingClientRect()
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => Number(element.dataset.index))
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = undefined
resizePinnedIndexes = []
})
})
}
const first = instance.range?.startIndex
resizeItem(index, size)
if (root && input.shouldAnchorBottom()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (input.shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
const virtualItemByKey = createMemo(
@@ -166,72 +226,92 @@ export function createTimelineVirtualizer(input: Input) {
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => {
input.onPin()
virtualizer.scrollToEnd()
})
input.setScrollToEnd?.(() => virtualizer.scrollToEnd())
input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
})
})
})
const maybeAnchorBottom = () => {
if (rows().length === 0) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
virtualizer.scrollToEnd()
}
let measuredSessionKey = input.sessionKey()
createEffect(() => {
const key = input.sessionKey()
rows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
}
maybeAnchorBottom()
})
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
root.style.overflowAnchor = "none"
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
input.setScrollRef(root)
}
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
if (!prependLoading) clearPrependAnchor()
const root = event.currentTarget
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
deltaMode: event.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture })
}
const handleListTouchStart = (event: TouchEvent) => {
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
if (!prependLoading) clearPrependAnchor()
touchGesture = event.touches[0]?.clientY
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
const current = event.touches[0]?.clientY
if (current === undefined || touchStart === undefined) return
// Dragging the content downward reveals earlier messages.
if (current <= touchStart) return
touchStart = current
input.onUnpin()
const next = event.touches[0]?.clientY
const previous = touchGesture
touchGesture = next
if (next === undefined || previous === undefined) return
const delta = previous - next
if (!delta) return
markBoundaryGesture({
root: event.currentTarget,
target: event.target,
delta,
onMarkScrollGesture: input.onMarkScrollGesture,
})
}
const handleListTouchEnd = () => {
touchGesture = undefined
}
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
const release = () => {
pointerHeld = false
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
}
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.target)
}
const handleListPointerMove = (event: PointerEvent) => {
if (event.buttons !== 1) return
input.onMarkScrollGesture(event.target)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
@@ -239,25 +319,18 @@ export function createTimelineVirtualizer(input: Input) {
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.currentTarget)
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
const root = event.currentTarget
const previousTop = scrollTop
const previousMaxScroll = maxScroll
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
input.onScheduleScrollState(root)
if (prependLoading) updatePrependAnchor()
input.onScheduleScrollState(event.currentTarget)
input.onHistoryScroll()
if (!input.hasScrollGesture()) return
input.onUserScroll()
input.onAutoScrollHandleScroll()
input.onMarkScrollGesture(event.currentTarget)
}
function View(props: ViewProps) {
@@ -308,10 +381,7 @@ export function createTimelineVirtualizer(input: Input) {
{props.renderRow(row, () => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (element.isConnected) virtualizer.measureElement(element)
})
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
})}
</div>
</div>
@@ -353,10 +423,13 @@ export function createTimelineVirtualizer(input: Input) {
onWheel={handleListWheel}
onTouchStart={handleListTouchStart}
onTouchMove={handleListTouchMove}
onTouchEnd={handleListTouchEnd}
onTouchCancel={handleListTouchEnd}
onPointerDown={handleListPointerDown}
onPointerMove={handleListPointerMove}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
onClick={input.onSelectionInteraction}
onClick={input.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
>
@@ -373,11 +446,10 @@ export function createTimelineVirtualizer(input: Input) {
<Show when={rows().length > 0}>
<div
data-timeline-row="bottom-spacer"
aria-hidden="true"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
/>
</Show>
</div>
</ScrollView>
@@ -386,13 +458,16 @@ export function createTimelineVirtualizer(input: Input) {
}
onCleanup(() => {
clearPrependAnchor()
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
})
return {
@@ -403,3 +478,30 @@ export function createTimelineVirtualizer(input: Input) {
View,
}
}
function boundaryTarget(root: HTMLElement, target: EventTarget | null) {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
if (!(nested instanceof HTMLElement) || nested === root) return undefined
return nested
}
function markBoundaryGesture(input: {
root: HTMLElement
target: EventTarget | null
delta: number
onMarkScrollGesture: (target?: EventTarget | null) => void
}) {
const target = boundaryTarget(input.root, input.target)
if (
target &&
!shouldMarkBoundaryGesture({
delta: input.delta,
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
})
)
return
input.onMarkScrollGesture(input.root)
}
@@ -15,7 +15,7 @@ export const useSessionHashScroll = (input: {
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: SessionMessageUser | undefined) => void
follow: { unpin: () => void; toBottom: () => void }
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
revealMessage?: (id: string) => void
@@ -101,7 +101,7 @@ export const useSessionHashScroll = (input: {
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
if (!hash) {
input.follow.toBottom()
input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -109,7 +109,7 @@ export const useSessionHashScroll = (input: {
const messageId = messageIdFromHash(hash)
if (messageId) {
input.follow.unpin()
input.autoScroll.pause()
const msg = messageById().get(messageId)
if (msg) {
scrollToMessage(msg, behavior)
@@ -120,12 +120,12 @@ export const useSessionHashScroll = (input: {
const target = document.getElementById(hash)
if (target) {
input.follow.unpin()
input.autoScroll.pause()
scrollToElement(target, behavior)
return
}
input.follow.toBottom()
input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
@@ -166,7 +166,7 @@ export const useSessionHashScroll = (input: {
if (pending) input.setPendingMessage(undefined)
if (input.currentMessageId() === targetId && !pending) return
input.follow.unpin()
input.autoScroll.pause()
cancel()
queue(() => scrollToMessage(msg, "auto"))
})
+1 -29
View File
@@ -7,7 +7,7 @@ import { TextInput } from "@opencode-ai/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
import { type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -123,33 +123,6 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props)
)
}
const TerminalPlacementSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: TerminalPlacement; label: string }[] => [
{ value: "side", label: language.t("settings.general.row.terminalPlacement.side") },
{ value: "bottom", label: language.t("settings.general.row.terminalPlacement.bottom") },
])
return (
<SettingsRow
title={language.t("settings.general.row.terminalPlacement.title")}
description={language.t("settings.general.row.terminalPlacement.description")}
>
<Select
data-action="settings-terminal-placement"
options={options()}
current={options().find((option) => option.value === settings.general.terminalPlacement())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setTerminalPlacement(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -300,7 +273,6 @@ export const SettingsGeneral: Component<{
<PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
-10
View File
@@ -6,7 +6,6 @@ import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export interface NotificationSettings {
agent: boolean
@@ -37,7 +36,6 @@ export interface Settings {
editToolPartsExpanded: boolean
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
terminalPlacement: TerminalPlacement
}
appearance: {
fontSize: number
@@ -123,7 +121,6 @@ const defaultSettings: Settings = {
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
},
appearance: {
fontSize: 14,
@@ -243,13 +240,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setMobileTitlebarPosition(value: "top" | "bottom") {
setStore("general", "mobileTitlebarPosition", value)
},
terminalPlacement: withFallback(
() => store.general?.terminalPlacement,
defaultSettings.general.terminalPlacement,
),
setTerminalPlacement(value: TerminalPlacement) {
setStore("general", "terminalPlacement", value)
},
},
visibility: {
fileTree: showFileTree,
@@ -93,24 +93,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
activeDirectory: props.activeDirectory,
}
}
// Fetch sessions per workspace directory instead of paging through every session on the server.
const loadSessions = async (directories: readonly string[], context = captureDeleteContext()) => {
const fetched = await Promise.all(
directories.map((directory) => listAllSessions(context.sdk.api.session, { order: "desc", directory })),
)
const sessions = fetched.flat()
return mergeWorkspaceSessionInventory(sessions, context.data.session.list())
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
fetched.forEach(context.data.session.remember)
return mergeWorkspaceSessionInventory(fetched, context.data.session.list())
}
const workspaceDirectories = createMemo(() => workspaces().map((workspace) => workspace.directory))
const sessionQuery = useQuery(() => ({
queryKey: [
serverSDK.scope,
null,
"settings-workspace-sessions",
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: workspaceDirectories().length > 0,
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions().then(() => Date.now()),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
@@ -118,7 +108,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
]),
),
)
@@ -146,7 +136,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions([workspace.directory], context),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
+25 -28
View File
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
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"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -24,32 +23,30 @@ export default function Layout(props: ParentProps) {
}
return (
<TitlebarRightProvider>
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
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>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
</TitlebarRightProvider>
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
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>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
)
}
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createTitlebarRightSlot } from "./right-slot"
describe("titlebar right slot", () => {
test("selects the latest owner and restores the previous owner after overlap", () => {
createRoot((dispose) => {
const slot = createTitlebarRightSlot()
const committed = slot.createRegistration()
committed.register()
expect(committed.active()).toBe(true)
const shadow = slot.createRegistration()
shadow.register()
expect(committed.active()).toBe(false)
expect(shadow.active()).toBe(true)
shadow.unregister()
expect(committed.active()).toBe(true)
expect(shadow.active()).toBe(false)
committed.unregister()
expect(committed.active()).toBe(false)
dispose()
})
})
})
@@ -1,65 +0,0 @@
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
type Registration = {
active: () => boolean
register: () => void
unregister: () => void
}
type TitlebarRightSlot = {
createRegistration: () => Registration
mount: () => HTMLElement | undefined
setMount: (mount: HTMLElement) => void
}
const TitlebarRightContext = createContext<TitlebarRightSlot>()
export function TitlebarRightProvider(props: ParentProps) {
return (
<TitlebarRightContext.Provider value={createTitlebarRightSlot()}>{props.children}</TitlebarRightContext.Provider>
)
}
export function createTitlebarRightSlot(): TitlebarRightSlot {
const [store, setStore] = createStore<{ mount?: HTMLElement; registrations: symbol[] }>({ registrations: [] })
return {
mount: () => store.mount,
setMount: (mount) => setStore("mount", mount),
createRegistration() {
const id = Symbol()
return {
active: () => store.registrations.at(-1) === id,
register: () => setStore("registrations", (items) => [...items, id]),
unregister: () => setStore("registrations", (items) => items.filter((item) => item !== id)),
}
},
}
}
export function TitlebarRightMount() {
const slot = useTitlebarRightSlot()
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
}
export function TitlebarRight(props: ParentProps) {
const slot = useTitlebarRightSlot()
const registration = slot.createRegistration()
onMount(() => {
registration.register()
onCleanup(registration.unregister)
})
return (
<Show when={registration.active() && slot.mount()} keyed>
{(mount) => <Portal mount={mount}>{props.children}</Portal>}
</Show>
)
}
function useTitlebarRightSlot() {
const slot = useContext(TitlebarRightContext)
if (!slot) throw new Error("TitlebarRight must be used within TitlebarRightProvider")
return slot
}
+22 -3
View File
@@ -1,4 +1,15 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -23,7 +34,6 @@ import { tabKey, useTabs } from "@/shell/tabs/tabs"
import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
const titlebarHeight = 36
const minTitlebarZoom = 0.25
@@ -36,6 +46,15 @@ export type TitlebarUpdate = {
install: () => void
}
export function useTitlebarRightMount() {
const language = useLanguage()
const [mount, setMount] = createSignal<HTMLElement | null>(null)
const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
onMount(sync)
createEffect(on(language.direction, sync, { defer: true }))
return mount
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const platform = usePlatform()
const command = useCommand()
@@ -402,7 +421,7 @@ function TitlebarRight(props: { state: TitlebarRightState }) {
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<TitlebarRightMount />
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
@@ -27,34 +27,6 @@ test("end anchoring survives consecutive resizes when the first scroll write is
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("start anchoring preserves a stable visible item across prepends", () => {
const root = document.createElement("div")
const writes: number[] = []
const options = (keys: string[]) => ({
count: keys.length,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 100 },
anchorTo: "start" as const,
getItemKey: (index: number) => keys[index]!,
getScrollElement: () => root,
scrollToFn: (offset: number) => writes.push(offset),
observeElementRect: () => {},
observeElementOffset: (_element: HTMLDivElement, callback: (offset: number, isScrolling: boolean) => void) => {
callback(50, false)
},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(["c", "d", "e"]))
virtualizer._willUpdate()
virtualizer.getVirtualItems()
virtualizer.setOptions(options(["a", "b", "c", "d", "e"]))
virtualizer._willUpdate()
expect(virtualizer.getScrollOffset()).toBe(150)
expect(writes.at(-1)).toBe(150)
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
+1 -42
View File
@@ -1,6 +1,5 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "vite"
import { VitePWA } from "vite-plugin-pwa"
import desktopPlugin from "./vite.js"
const sentry =
@@ -21,47 +20,7 @@ const sentry =
: false
export default defineConfig({
plugins: [
desktopPlugin,
VitePWA({
strategies: "generateSW",
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/],
globPatterns: [
"index.html",
"site.webmanifest",
"favicon*",
"apple-touch-icon*",
"web-app-manifest*",
"assets/index-*.{js,css}",
"assets/session-*.js",
"assets/Inter.ttf",
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
],
runtimeCaching: [
{
urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith("/assets/"),
handler: "CacheFirst",
options: {
cacheName: "opencode-assets",
cacheableResponse: {
statuses: [200],
},
expiration: {
maxEntries: 1000,
},
},
},
],
},
}),
sentry,
] as any,
plugins: [desktopPlugin, sentry] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
+1 -2
View File
@@ -32,10 +32,9 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets:
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
const revalidate = html || name === "sw.js" || name === "registerSW.js"
const headers = {
"content-type": FSUtil.mimeType(name),
"cache-control": revalidate ? "no-cache" : "public, max-age=31536000, immutable",
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
"content-security-policy": html
? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString())
: csp(),
-9
View File
@@ -20,8 +20,6 @@ describe("web UI", () => {
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"sw.js": "service worker",
"registerSW.js": "registration",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
}
@@ -55,13 +53,6 @@ describe("web UI", () => {
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable")
const worker = yield* Effect.promise(() => fetch(`${origin}/sw.js`))
expect(worker.headers.get("cache-control")).toBe("no-cache")
const registration = yield* Effect.promise(() => fetch(`${origin}/registerSW.js`))
expect(registration.headers.get("cache-control")).toBe("no-cache")
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
-3
View File
@@ -1101,9 +1101,6 @@ export function createData(config: CreateDataInput) {
get(sessionID: string) {
return store.session.info[sessionID]
},
creating(sessionID: string) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
+1 -32
View File
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
@@ -72,37 +72,6 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
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")) throw new Error(`Unexpected request: ${request.url}`)
await release.promise
return Response.json({ data: session(0) })
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen: () => () => {},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))
try {
const created = setup.data.session.create({ id: "ses_refresh", location: { directory: "/project" } })
expect(setup.data.session.creating(created.id)).toBe(true)
release.resolve()
await created.request
expect(setup.data.session.creating(created.id)).toBe(false)
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
-74
View File
@@ -1,74 +0,0 @@
export * as LocationActivity from "./location-activity.js"
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const isSessionEvent = Schema.is(SessionEvent.Durable)
export class Service extends Context.Service<Service, {}>()("@opencode/LocationActivity") {}
export function layer(options: { readonly timeToLive?: Duration.Input; readonly sweepInterval?: Duration.Input } = {}) {
return Layer.effect(
Service,
Effect.gen(function* () {
const clock = yield* Clock.Clock
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
const touch = (ref: Location.Ref) =>
Effect.sync(() => {
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
})
const unsubscribe = yield* bus.listen((event) => {
if (!isSessionEvent(event)) return Effect.void
const location = event.location
if (!location) return Effect.void
return RcMap.has(locations.rcMap, location).pipe(
Effect.flatMap((active) => (active ? touch(location) : Effect.void)),
)
})
yield* Effect.addFinalizer(() => unsubscribe)
yield* Effect.gen(function* () {
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
const cached = new Set(refs.map(key))
yield* Effect.forEach(
refs,
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
{ discard: true },
)
for (const id of entries.keys()) {
if (!cached.has(id)) entries.delete(id)
}
const now = clock.currentTimeMillisUnsafe()
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
yield* Effect.forEach(
expired,
(entry) => {
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
},
{ discard: true },
)
}).pipe(Effect.forever, Effect.forkScoped)
return Service.of({})
}),
)
}
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [Bus.node, LocationServiceMap.node],
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { Duration, Effect, Layer, LayerMap } from "effect"
import { Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { Agent } from "./agent.js"
@@ -147,7 +147,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
),
(inner) => ({
...inner,
+16 -29
View File
@@ -541,9 +541,6 @@ const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
// Digest of the raw body, persisted so refresh() can skip republishing a
// byte-identical catalog. Optional for entries written before it existed.
digest: Schema.optional(Schema.String),
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
@@ -571,10 +568,6 @@ function cacheKey(source: string) {
return `models-dev:catalog:${Hash.fast(source)}`
}
export function bodyDigest(text: string) {
return new Bun.CryptoHasher("sha256").update(text).digest("hex")
}
export const layer = (options?: Options) =>
Layer.effect(
Service,
@@ -607,11 +600,16 @@ export const layer = (options?: Options) =>
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
digest: cached.value.digest,
}
if (value !== undefined) yield* kv.remove(key)
})
const fresh = Effect.fnUntraced(function* () {
const cached = yield* loadFromCache()
if (!cached) return false
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
})
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", userAgent),
@@ -632,23 +630,19 @@ export const layer = (options?: Options) =>
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
})
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
yield* writeCache(text)
return catalog
})
@@ -678,15 +672,8 @@ export const layer = (options?: Options) =>
yield* lock
.withPermit(
Effect.gen(function* () {
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === bodyDigest(text)) return
yield* decodeCatalog(text)
yield* writeCache(text)
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
+1 -1
View File
@@ -130,7 +130,7 @@ secret into configuration.
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/src/docs/content/migrate-v1.mdx`.
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
+2 -52
View File
@@ -3,16 +3,14 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
@@ -23,7 +21,6 @@ import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
@@ -44,55 +41,8 @@ const itWithSdk = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const activityLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref) =>
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
}),
) as unknown as Layer.Layer<LocationServices>,
{ idleTimeToLive: Duration.infinity },
),
)
const itWithActivity = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
[[LocationServiceMap.node, activityLocations]],
),
)
describe("LocationServiceMap", () => {
itWithActivity.effect("refreshes lifetime from Session events only", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const sessionID = Session.ID.make("ses_location_activity")
const read = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
yield* read
yield* TestClock.adjust("59 minutes")
yield* bus.publish(Catalog.Event.Updated, {}, { location: ref })
yield* TestClock.adjust("2 minutes")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
yield* read
yield* bus.publish(SessionEvent.Execution.Started, { sessionID }, { location: ref })
yield* TestClock.adjust("59 minutes")
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, { location: ref })
yield* TestClock.adjust("1 minute")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([ref])
yield* TestClock.adjust("59 minutes")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
)
it.live("retries a location after its missing directory is recreated", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+7 -90
View File
@@ -1,14 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect"
@@ -181,7 +180,7 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
@@ -200,16 +199,13 @@ const makeFailingWriteKV = (cache: MockCache) =>
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
cache.values.set(cacheKey, { updatedAt, body: text })
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
const provided = <A, E>(
state: Ref.Ref<MockState>,
cache: MockCache,
eff: Effect.Effect<A, E, ModelsDev.Service | Bus.Service | Scope.Scope>,
) => eff.pipe(Effect.provide(buildLayer(state, cache)))
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state, cache)))
const initialState: MockState = {
body: JSON.stringify(fixture),
@@ -395,20 +391,7 @@ describe("ModelsDev Service", () => {
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
yield* svc.refresh(false)
return yield* svc.get()
}),
)
@@ -418,72 +401,6 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) stays quiet when the fetched body matches the cached digest", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const seeded = structuredClone(cache.values.get(cacheKey))
// The server serves a byte-identical body, so the refresh still hits
// the network but must not rewrite the cache or publish Refreshed.
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
)
expect(event._tag).toBe("None")
}),
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(cache.values.get(cacheKey)).toEqual(seeded)
}),
)
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
Effect.gen(function* () {
const cache = makeCache()
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
}),
)
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
}),
)
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
const cache = makeCache()
+1 -2
View File
@@ -147,9 +147,8 @@ const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
export const layer = Layer.unwrap(
Effect.gen(function* () {
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
yield* configureApplication()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
-2
View File
@@ -20,7 +20,6 @@ import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -63,7 +62,6 @@ const applicationServiceNodes = [
WellKnown.node,
PtyEnvironment.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createTwoFilesPatch } from "diff"
import { patchFile, patchFileGroups, patchFiles } from "./apply-patch-file"
import { patchFile, patchFiles } from "./apply-patch-file"
describe("apply patch files", () => {
test("parses current file diffs", () => {
@@ -29,46 +28,4 @@ describe("apply patch files", () => {
{ path: "src/old.ts", type: "delete" },
])
})
test("composes sequential complete patches for the same file", () => {
const before = "const a = 1\nconst b = 2\n"
const middle = "const a = 2\nconst b = 2\n"
const after = "const a = 2\nconst b = 3\n"
const patch = (oldText: string, newText: string) =>
createTwoFilesPatch("a/src/a.ts", "b/src/a.ts", oldText, newText).replace(
/^(?:Index: [^\n]+\n)?=+\n/,
"diff --git a/src/a.ts b/src/a.ts\n",
)
const groups = patchFileGroups([
{
file: "src/a.ts",
patch: patch(before, middle),
additions: 1,
deletions: 1,
status: "modified",
},
{
file: "src/a.ts",
patch: patch(middle, after),
additions: 1,
deletions: 1,
status: "modified",
},
])
expect(groups).toHaveLength(1)
expect(groups[0]?.views).toHaveLength(1)
expect(groups[0]?.additions).toBe(2)
expect(groups[0]?.deletions).toBe(2)
})
test("keeps sequential partial patches under one file", () => {
const groups = patchFileGroups([
{ file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b", additions: 1, deletions: 1, status: "modified" },
{ file: "src/a.ts", patch: "@@ -2 +2 @@\n-c\n+d", additions: 1, deletions: 1, status: "modified" },
])
expect(groups).toHaveLength(1)
expect(groups[0]?.views).toHaveLength(2)
})
})
@@ -1,6 +1,5 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { diffLines } from "diff"
import { completePatchContents, normalize, type ViewDiff } from "./session-diff"
import { normalize, type ViewDiff } from "./session-diff"
type Kind = "add" | "update" | "delete"
@@ -10,11 +9,8 @@ export type ApplyPatchFile = {
additions: number
deletions: number
view: ViewDiff
contents?: { before: string; after: string }
}
export type ApplyPatchFileGroup = Omit<ApplyPatchFile, "view" | "contents"> & { views: ViewDiff[] }
function fileDiff(value: unknown): value is FileDiffInfo {
if (!value || typeof value !== "object") return false
if (!("file" in value) || typeof value.file !== "string") return false
@@ -33,7 +29,6 @@ export function patchFile(value: unknown): ApplyPatchFile | undefined {
additions: value.additions,
deletions: value.deletions,
view: normalize(value),
contents: completePatchContents(value.patch),
}
}
@@ -41,53 +36,3 @@ export function patchFiles(value: unknown) {
if (!Array.isArray(value)) return []
return value.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
}
export function patchFileGroups(value: unknown): ApplyPatchFileGroup[] {
const groups = patchFiles(value).reduce((result, file) => {
const files = result.get(file.path)
if (files) files.push(file)
if (!files) result.set(file.path, [file])
return result
}, new Map<string, ApplyPatchFile[]>())
return [...groups].map(([path, files]) => {
const first = files[0]!
const last = files.at(-1)!
const type = last.type === "delete" ? "delete" : first.type === "add" ? "add" : "update"
const chained = files.every(
(file, index) => !!file.contents && (index === 0 || files[index - 1]?.contents?.after === file.contents.before),
)
if (!chained) {
return {
path,
type,
additions: files.reduce((total, file) => total + file.additions, 0),
deletions: files.reduce((total, file) => total + file.deletions, 0),
views: files.map((file) => file.view),
}
}
const before = first.contents!.before
const after = last.contents!.after
const counts = diffLines(before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
path,
type,
...counts,
views: [
normalize({
file: path,
before,
after,
status: type === "add" ? "added" : type === "delete" ? "deleted" : "modified",
...counts,
}),
],
}
})
}
@@ -116,7 +116,7 @@
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
&.clickable:not(.webfetch-link) {
&.clickable {
cursor: pointer;
text-decoration: underline;
transition: color 0.15s ease;
@@ -170,31 +170,6 @@
}
}
[data-component="collapsible"].tool-collapsible[data-compact="true"] > [data-slot="collapsible-trigger"] {
height: 28px;
[data-component="tool-trigger"],
[data-slot="basic-tool-tool-info-main"] {
gap: 6px;
}
[data-slot="basic-tool-tool-title"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 530;
line-height: var(--v2-line-height-compact, 16px);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--v2-line-height-compact, 16px);
letter-spacing: -0.04px;
}
}
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
@@ -281,24 +256,6 @@
}
}
[data-component="collapsible"].tool-collapsible:not([data-rail="false"]) {
> [data-slot="collapsible-content"] {
position: relative;
margin-inline-start: 12px;
padding-inline-start: 16px;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
top: 0;
bottom: 12px;
width: 0.5px;
background-color: var(--v2-border-border-muted, rgba(0, 0, 0, 0.08));
}
}
}
:root body {
[data-component="task-tool-card"] {
gap: 8px;
@@ -368,72 +325,3 @@
}
}
}
.webfetch-link,
[data-slot="basic-tool-tool-subtitle"].webfetch-link,
[data-slot="exa-tool-link"].webfetch-link,
[data-component="tool-trigger"] [data-slot="basic-tool-tool-subtitle"].webfetch-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--v2-text-text-accent);
text-decoration: none;
overflow: visible;
max-width: 100%;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: inherit;
font-style: normal;
font-weight: var(--font-weight-regular, 440);
line-height: inherit;
letter-spacing: var(--letter-spacing-normal);
&:visited,
&:active {
color: var(--v2-text-text-accent);
}
[data-slot="webfetch-link-text"] {
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: inherit;
}
.webfetch-link-icon {
display: none;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--v2-icon-icon-accent, var(--v2-text-text-accent));
}
&:hover {
color: var(--v2-text-text-accent);
text-decoration: none;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
}
@@ -0,0 +1,75 @@
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "./basic-tool"
export default {
title: "OpenCode/Tools/Disclosure",
id: "components-basic-tool",
component: BasicTool,
parameters: {
docs: {
description: {
component:
"The disclosure frame shared by production tool messages. Use these stories to inspect common resting, running, expanded, and summary-only states.",
},
},
},
}
export const Completed = {
render: () => (
<BasicTool
icon="glasses"
defaultOpen
trigger={{ title: "Read", subtitle: "src/session.ts", args: ["offset=1", "limit=80"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">Loaded the requested file.</div>
</BasicTool>
),
}
export const Running = {
render: () => (
<BasicTool icon="console" status="running" trigger={{ title: "Running tests", subtitle: "bun test src/timeline" }}>
<div class="px-3 py-2 font-mono text-12-regular text-text-base">Running timeline tests...</div>
</BasicTool>
),
}
export const Collapsed = {
render: () => (
<BasicTool
icon="magnifying-glass-menu"
trigger={{ title: "Searched", subtitle: "packages/session-ui", args: ["pattern=TimelineRow.key"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">2 matching files</div>
</BasicTool>
),
}
export const SummaryOnly = {
render: () => (
<BasicTool icon="post-skill" hideDetails trigger={{ title: "Skill", subtitle: "rtl-aware-development" }} />
),
}
export const Controlled = {
render: () => {
const [state, setState] = createStore({ open: false })
return (
<div class="flex max-w-[620px] flex-col gap-3">
<Button class="w-fit" size="small" variant="neutral" onClick={() => setState("open", (value) => !value)}>
{state.open ? "Close tool details" : "Open tool details"}
</Button>
<BasicTool
icon="code-lines"
open={state.open}
onOpenChange={(open) => setState("open", open)}
trigger={{ title: "Edited", subtitle: "src/session.ts", args: ["+3", "-1"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">Changed the active Session label.</div>
</BasicTool>
</div>
)
},
}
@@ -36,14 +36,12 @@ export interface BasicToolProps {
defer?: boolean
locked?: boolean
animated?: boolean
rail?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
triggerHref?: string
triggerAsLink?: boolean
clickable?: boolean
compact?: boolean
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
@@ -257,35 +255,16 @@ export function BasicTool(props: BasicToolProps) {
)
return (
<Collapsible
open={open()}
onOpenChange={props.locked ? undefined : handleOpenChange}
class="tool-collapsible"
data-compact={props.compact ? "true" : undefined}
data-rail={props.rail === false ? "false" : undefined}
>
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Show
when={!props.locked && (props.triggerAsLink || props.triggerHref)}
when={props.triggerAsLink || props.triggerHref}
fallback={
<Show
when={!props.locked}
fallback={
<div
data-slot="collapsible-trigger"
data-locked
data-hide-details={props.hideDetails ? "true" : undefined}
>
{trigger()}
</div>
}
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
</Show>
{trigger()}
</Collapsible.Trigger>
}
>
<Collapsible.Trigger
+1 -1
View File
@@ -702,7 +702,7 @@ function ViewerShell(props: {
data-mode={props.mode}
dir="ltr"
style={styleVariables}
class="relative select-text outline-none"
class="relative outline-none"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
@@ -326,7 +326,7 @@
[data-component="tool-output"] {
white-space: pre;
padding: 0;
margin-bottom: 0px;
margin-bottom: 24px;
height: fit-content;
display: flex;
flex-direction: column;
@@ -569,13 +569,12 @@
}
[data-component="exa-tool-output"] {
width: 100%;
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
font-family: var(--font-family-sans);
font-size: 13px;
line-height: 16px;
font-size: var(--font-size-base);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
}
@@ -590,39 +589,27 @@
[data-slot="exa-tool-links"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
flex: 1 0 0;
gap: 4px;
}
[data-slot="exa-tool-link"] {
width: fit-content;
display: block;
max-width: 100%;
align-self: flex-start;
}
[data-slot="exa-tool-more"] {
all: unset;
cursor: pointer;
width: fit-content;
font-family: var(--font-family-sans);
font-size: 13px;
font-weight: var(--font-weight-regular, 440);
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-faint, #808080);
user-select: none;
font: inherit;
line-height: inherit;
color: var(--v2-text-text-accent);
text-decoration: underline;
text-underline-offset: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--v2-text-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
color: var(--v2-text-text-accent);
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
&:visited {
color: var(--v2-text-text-accent);
}
}
@@ -668,7 +655,10 @@
}
[data-component="context-tool-group-list"] {
padding: 0;
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
padding-left: 12px;
display: flex;
flex-direction: column;
gap: 4px;
@@ -1226,19 +1216,10 @@
> [data-component="collapsible"] > [data-slot="collapsible-content"] {
border: none;
border-inline-start: none;
margin-inline-start: 0;
padding-inline-start: 0;
padding-bottom: 0;
background: transparent;
&::before {
display: none;
}
}
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"],
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][data-locked] {
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] {
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
@@ -1339,36 +1320,20 @@
}
}
[data-component="tool-loaded-item"] {
[data-component="tool-loaded-file"] {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
gap: 8px;
padding: 4px 0 4px 28px;
font-family: var(--font-family-sans);
font-size: 13px;
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
[data-slot="tool-loaded-label"] {
[data-slot="icon-svg"] {
flex-shrink: 0;
font-weight: 530;
}
[data-slot="tool-loaded-value"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 440;
}
[data-slot="tool-loaded-kind"] {
flex-shrink: 0;
margin-inline-start: -2px;
font-weight: 440;
color: var(--v2-text-text-muted);
color: var(--icon-weak);
}
}
@@ -70,7 +70,7 @@ function fileDiffFromPatch(file: string, patch: string) {
return value
}
export function completePatchContents(patch: string) {
function completePatchContents(patch: string) {
try {
const parsed = parsePatch(patch)[0]
if (!parsed || (!parsed.index && !parsed.oldFileName && !parsed.newFileName)) return
@@ -17,17 +17,6 @@
> [data-component="collapsible"].tool-collapsible {
gap: 0px;
> [data-slot="collapsible-content"] {
border-inline-start: none;
margin-inline-start: 0;
padding-inline-start: 0;
padding-bottom: 0;
&::before {
display: none;
}
}
}
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
@@ -26,26 +26,10 @@ describe("current content default open", () => {
test("uses the file-change disclosure preference", () => {
expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true)
expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false)
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true)
})
test("collapses failed patches", () => {
const patch: SessionMessageAssistantTool = {
type: "tool",
id: "tool_patch",
name: "patch",
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "Verification failed" },
metadata: {},
},
time: { created: 1, completed: 2 },
}
expect(currentContentDefaultOpen(patch, false, false)).toBe(false)
})
test("opens deletion-only patches", () => {
test("keeps deletion-only changes collapsed", () => {
expect(
currentContentDefaultOpen(
tool("patch", [
@@ -55,6 +39,6 @@ describe("current content default open", () => {
false,
true,
),
).toBe(true)
).toBe(false)
})
})
@@ -6,7 +6,7 @@ import type {
import { Match, Switch } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { CurrentContextToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -109,19 +109,3 @@ export function SessionContextToolGroup(props: {
/>
)
}
export function SessionFileToolGroup(props: {
tools: SessionMessageAssistantTool[]
fileOpen: (path: string) => boolean | undefined
onFileOpenChange: (path: string, open: boolean) => void
onSizeChange?: () => void
}) {
return (
<CurrentFileToolGroup
tools={props.tools}
fileOpen={props.fileOpen}
onFileOpenChange={props.onFileOpenChange}
onSizeChange={props.onSizeChange}
/>
)
}
@@ -36,8 +36,7 @@ export function currentContentDefaultOpen(
) {
if (content.type !== "tool") return undefined
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name === "patch") return content.state.status !== "error"
if (content.name !== "edit" && content.name !== "write") return undefined
if (content.name !== "edit" && content.name !== "write" && content.name !== "patch") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
if (!Array.isArray(files) || files.length === 0) return true
@@ -702,32 +702,15 @@ export const webResearchDocument = document([
id: "tool_web_search",
name: "websearch",
offset: 73_100,
args: { query: "figma mcp setup" },
output: [
"https://www.figma.com/community/file/1606560040358762787/figma-mcp-console-setup-guide",
"https://designagentlab.com",
"https://www.figma.com/community/whiteboarding?resource_type=widgets",
"https://figma-console-mcp.southleft.com/mcp",
"https://designagentlab.com/figma-console-mcp",
"https://designagentlab.com/figma-tutorials",
"https://github.com/southleft/figma-console-mcp/issues",
"https://designagentlab.com/ui-kits",
"https://designagentlab.com/prototyping-tools",
"https://www.inthepocket.design/guidelines/figma-mcp/setup-figma-mcp",
"https://www.figma.com/community/plugins",
"https://figma-console-mcp.southleft.com/docs",
"https://designagentlab.com/resources",
"https://github.com/southleft/figma-console-mcp/releases",
"https://www.inthepocket.design/blog/figma-mcp",
"https://designagentlab.com/community",
].join("\n"),
metadata: { provider: "firecrawl" },
args: { query: "WAI ARIA live region status message guidance" },
output: "WAI-ARIA Authoring Practices and MDN live region guidance",
metadata: { provider: "exa" },
}),
completedTool({
id: "tool_web_fetch",
name: "webfetch",
offset: 74_000,
args: { url: "https://www.figma.com" },
args: { url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html" },
output: "Status messages should be programmatically determinable without receiving focus.",
}),
],
@@ -745,25 +728,33 @@ export const webResearchDocument = document([
}),
] satisfies SessionMessageInfo[])
export const loadedResourcesDocument = document([
user("msg_user_skill", "Read the project instructions, load the RTL-aware skill, and review the file row.", 79_000),
export const skillWorkflowDocument = document([
{
id: "msg_agent_switched_review",
type: "agent-switched",
agent: "review",
previous: "build",
time: { created: STORY_TIME + 78_000 },
},
{
id: "msg_skill_loaded_rtl",
type: "skill",
skill: "rtl-aware-development",
name: "RTL-aware development",
text: "Verify direction independently from language.",
time: { created: STORY_TIME + 78_500 },
},
user("msg_user_skill", "Review the mixed-direction file row before I merge it.", 79_000),
assistant({
id: "msg_assistant_skill",
offset: 80_000,
completed: 82_000,
agent: "review",
content: [
completedTool({
id: "tool_loaded_file",
name: "read",
offset: 80_100,
args: { path: "C:/workspaces/opencode/packages/cli/AGENTS.md" },
output: "Project instructions loaded.",
metadata: { loaded: ["C:/workspaces/opencode/packages/cli/AGENTS.md"] },
}),
completedTool({
id: "tool_skill_rtl",
name: "skill",
offset: 80_200,
offset: 80_100,
args: { name: "rtl-aware-development" },
output: "Loaded RTL-aware development guidance",
metadata: { name: "rtl-aware-development" },
@@ -776,50 +767,6 @@ export const loadedResourcesDocument = document([
}),
] satisfies SessionMessageInfo[])
export const instructionsUpdatedSingleDocument = document([
user("msg_user_instructions_single", "Check if beta service reports the shared session as running.", 85_000),
assistant({
id: "msg_assistant_instructions_single",
offset: 86_000,
completed: 88_000,
content: [
{
type: "text",
text: "The beta service is healthy and already reports this shared session as running. I found unrelated desktop changes in the worktree and will leave them untouched; next I'm narrowing the beta-only capabilities to features that can be demonstrated safely in this session rather than invoking every administrative API.",
},
],
}),
{
id: "msg_instructions_updated_single",
type: "system",
text: "Updated instructions for api/v2-demo",
description: "Instructions updated: api/v2-demo",
time: { created: STORY_TIME + 89_000 },
},
] satisfies SessionMessageInfo[])
export const instructionsUpdatedMultipleDocument = document([
user("msg_user_instructions_multi", "Check if beta service reports the shared session as running.", 85_000),
assistant({
id: "msg_assistant_instructions_multi",
offset: 86_000,
completed: 88_000,
content: [
{
type: "text",
text: "The beta service is healthy and already reports this shared session as running. I found unrelated desktop changes in the worktree and will leave them untouched; next I'm narrowing the beta-only capabilities to features that can be demonstrated safely in this session rather than invoking every administrative API.",
},
],
}),
{
id: "msg_instructions_updated_multi",
type: "system",
text: "Updated instructions for api/v2-demo and api/session",
description: "Instructions updated: api/v2-demo, api/session",
time: { created: STORY_TIME + 89_000 },
},
] satisfies SessionMessageInfo[])
export const permissionPendingDocument = document(
[
user("msg_user_permission_pending", "Publish the verified preview build to the canary channel.", 83_000),
+1 -1
View File
@@ -23,7 +23,7 @@ export {
retryDocument,
revertDocument,
reviewDiffs,
loadedResourcesDocument,
skillWorkflowDocument,
standaloneShellCompletedDocument,
standaloneShellRunningDocument,
streamingDocument,

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