Compare commits

..
Author SHA1 Message Date
neriousy 1dd6600afc fix(core): bypass Windows Git lookup 2026-08-21 22:16:46 +00:00
393 changed files with 19465 additions and 26613 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
+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
+6 -37
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 }}
@@ -195,33 +195,9 @@ jobs:
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-app-archive:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build app archive
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-app-archive
path: packages/cli/.cache/app-archive.bin
if-no-files-found: error
build-node-cli:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode'
needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
strategy:
fail-fast: false
matrix:
@@ -234,7 +210,6 @@ jobs:
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
bun_install_flags: --cpu=*
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
@@ -246,19 +221,14 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
install-flags: ${{ matrix.settings.bun_install_flags }}
install-flags: --os=* --cpu=*
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-node-app-archive
path: packages/cli/.cache
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -573,7 +543,6 @@ jobs:
- version
- build-cli
- sign-cli-macos
- build-node-app-archive
- build-node-cli
- sign-cli-windows
- build-electron
+617 -337
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-P8AZ2THuXiET4hiSYTwGGdTgbm2l3XGZGJxQwg6lusE=",
"aarch64-linux": "sha256-73LxDeDPQspY4Bn/gbGU43j/k7gBZ5ywMMpUjDl3elg=",
"aarch64-darwin": "sha256-sc9YyTAFlbWxlFb9vwFYLs6IYd3SRoju9mQufzeQdd8=",
"x86_64-darwin": "sha256-nnk637DELjzGB3t9aLDYbHz5h3Dd3nvWcWGdaQk8yPM="
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
}
}
+5 -5
View File
@@ -49,10 +49,10 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@tanstack/solid-virtual": "3.13.37",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@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
+58 -74
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,14 +54,9 @@ 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 messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -90,7 +81,6 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
OpenResponsesInputText,
OpenResponsesInputImage,
OpenResponsesInputFile,
OpenResponsesInputVideo,
])
const OpenResponsesFunctionCallOutput = Schema.Union([
@@ -247,7 +237,6 @@ const OpenResponsesErrorPayload = Schema.Struct({
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
@@ -312,6 +301,20 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension {
readonly id: string
readonly name: string
@@ -320,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 }
@@ -332,6 +336,7 @@ export interface ParserState {
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
@@ -409,29 +414,26 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
return itemID(part.providerMetadata, providerMetadataKey)
}
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* (
@@ -440,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* (
@@ -463,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,
)
})
@@ -529,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] })
@@ -579,19 +560,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
if (store !== false && id && !hostedToolReferences.has(id))
input.push({ type: "item_reference", id })
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
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 (id) hostedToolReferences.add(id)
if (itemID) hostedToolReferences.add(itemID)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -803,17 +782,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id) {
const phase = messagePhase(item.phase)
if (item?.type === "message" && item.id)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
},
NO_EVENTS,
]
}
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -971,7 +951,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemPhase = messagePhase(item.phase)
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
@@ -1079,26 +1059,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: Event, fallback: string): string => {
const nested = event.error ?? event.response?.error ?? undefined
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
return message || code || fallback
}
export const providerFailure = (id: string, event: Event, fallback: string) => {
const nested = event.error ?? event.response?.error ?? undefined
const code = event.code || nested?.code || undefined
// Keep the full raw payload on the error even when the message is a summary.
const body = JSON.stringify(nested ?? event) ?? ""
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
const status =
typeof event.status === "number"
? event.status
@@ -1108,8 +1084,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
return new AIError({
module: id,
method: "stream",
body,
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
reason: classifyProviderFailure({ message, code, status }),
})
}
@@ -1125,18 +1100,21 @@ export const step = (state: ParserState, event: Event) => {
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
@@ -1181,11 +1159,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
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({
id: ADAPTER,
body: {
+1 -1
View File
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.undefined
return Effect.succeed(undefined)
})
const multipartMask =
mask === undefined
@@ -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,
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
url: string,
module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.undefined
if (!url.startsWith("data:")) return Effect.succeed(undefined)
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
@@ -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"
+2 -7
View File
@@ -80,9 +80,6 @@ export interface ProviderFailure {
readonly message: string
readonly status?: number | undefined
readonly code?: string | undefined
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
// that the summary message does not carry. Not shown to users.
readonly rawBody?: string | undefined
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http?: HttpContext | undefined
@@ -92,13 +89,11 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? input.rawBody ?? ""
const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase())
// Scan the raw payload too so signals missing from the summary message
// (e.g. overflow phrases nested in a JSON error body) still classify.
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const text = body || input.message
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
+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,
-3
View File
@@ -153,9 +153,6 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
module: Schema.String,
method: Schema.String,
reason: AIErrorReason,
// Raw provider payload as a string, so classified failures never lose the
// original error detail even when the pretty message is a summary.
body: Schema.optional(Schema.String),
}) {
override readonly cause = this.reason
@@ -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,
-17
View File
@@ -106,20 +106,3 @@ describe("provider error classification", () => {
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
})
})
describe("provider error rawBody classification", () => {
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
})
expect(reason._tag).toBe("InvalidRequest")
expect(reason).toMatchObject({ classification: "context-overflow" })
})
test("extracts nested codes from the raw payload", () => {
expect(
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
})
})
+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(
@@ -3016,42 +2933,36 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to the raw payload when error is null", () =>
it.effect("falls back to a stable default when error is null", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"error":null')
expect(error.body).toBe(error.reason.message)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
}),
)
it.effect("falls back to the raw payload when both error and response are absent", () =>
it.effect("falls back to a stable default when both error and response are absent", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
}),
)
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"resp_failed_3"')
expect(error.body).toBe(error.reason.message)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
}),
)
@@ -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,
})
}),
)
})
-6
View File
@@ -19,12 +19,6 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Typography
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
@@ -259,34 +259,6 @@ export function event(
return makeEvent(type, data)
}
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
return makeEvent("session.compaction.started", data)
}
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
return makeEvent("session.compaction.delta", data)
}
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
return makeEvent("session.compaction.ended", data)
}
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
return makeEvent("session.compaction.failed", 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"
@@ -53,37 +53,6 @@ benchmark.describe("performance: first navigation paint", () => {
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { draftID })
await page.goto("/")
const draftHref = stressDraftHref(draftID)
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
await expect(draftTab).toHaveCount(1)
await draftTab.click()
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
await expect(sessionTab).toHaveCount(1)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: '[data-component="new-session"]',
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await sessionTab.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
@@ -45,17 +45,21 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a diff line is hovered", async ({ page }) => {
test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -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())
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -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,
toolPart,
userMessage,
userText,
} 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,144 +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")
await setupTimeline(page, {
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
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,
},
],
},
},
),
]),
],
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 row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(1)
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)
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, -20)
await expect
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
.toBeGreaterThan(0)
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element, collapsed) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
const allocatedHeight = element.getBoundingClientRect().height
return {
grew: allocatedHeight > collapsed + 1,
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
}
}, collapsedHeight),
)
.toEqual({ grew: true, measured: true })
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 expect
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
.toBeGreaterThan(50)
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
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(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
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") {
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 20
const messagePageSize = 200
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -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,15 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import {
compactionDelta,
compactionEnded,
compactionFailed,
compactionStarted,
event,
session,
sessionID,
setupTimeline,
} 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
@@ -82,160 +73,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
sessionID,
reason: "manual",
recent: "",
}),
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
sessionID,
text: "## Checkpoint\n\nStreamed implementation details.",
}),
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.send(
compactionEnded({
sessionID,
reason: "manual",
text: "## Checkpoint\n\nFinal implementation details.",
recent: "",
}),
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}),
)
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await timeline.send(
compactionFailed({
sessionID,
reason: "manual",
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
}),
)
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
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", "16px")
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", "16px")
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"]')
@@ -244,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) =>
@@ -284,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 }) => {
@@ -298,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 }) => {
@@ -380,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"),
],
},
},
)
@@ -98,9 +98,9 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
const rows = group.locator('[data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
@@ -111,11 +111,7 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
@@ -125,23 +121,25 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
for (const [id, name] of [
[pending, "frontend-design"],
[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).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
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()
}
})
@@ -26,42 +26,6 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("returns to the parent session with Escape", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.keyboard.press("Escape")
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
})
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -86,44 +50,6 @@ test("keeps the parent visible while the child session resolves", async ({ page
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.goBack()
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
const parentTab = page.locator("[data-titlebar-tab-slot]", {
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
})
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(parentTab).toHaveAttribute("data-active", "true"),
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
const home = page.getByRole("button", { name: "Home" })
await home.click()
await expect(page).toHaveURL("/")
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
await expect(childTab).toHaveCount(1)
await childTab.click()
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))
@@ -9,14 +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[] = []
let sendPtyOutput: ((data: string) => void) | undefined
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
ptyInput.length = 0
sendPtyOutput = undefined
await mockOpenCodeServer(page, {
directory,
project: {
@@ -74,50 +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()))
sendPtyOutput = (data) => ws.send(data)
})
})
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")
})
test("hides the native contenteditable caret", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)")
})
test("reveals the terminal after its first server output renders", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("opacity", "0")
await expect.poll(() => sendPtyOutput).toBeDefined()
sendPtyOutput?.("\x1b[?25h")
await expect(terminal).toHaveCSS("opacity", "0")
sendPtyOutput?.("ready")
await expect(terminal).toHaveCSS("opacity", "1")
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
@@ -358,8 +358,7 @@ test.describe("smoke: session timeline", () => {
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("bun typecheck")
await expect(shell.locator('[data-slot="bash-result"]')).not.toContainText("bun typecheck")
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
-2
View File
@@ -48,11 +48,9 @@
"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": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
-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 -7
View File
@@ -1,5 +1,4 @@
import type { Data } from "@opencode-ai/client/solid"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
@@ -42,10 +41,6 @@ export type ComposerSelection = {
export type ComposerSession = {
id: string
directory: string
handoff?: {
set: (message: SessionMessageUser) => void
clear: (messageID: string) => void
}
api: {
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
@@ -56,7 +51,6 @@ export type ComposerSession = {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
setStatus: Data["session"]["setStatus"]
}
}
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
@@ -83,7 +77,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
start: (
selection: ComposerSelection,
submission: ReturnType<typeof createComposerSubmission>,
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
) => Promise<ComposerSession | undefined>
}
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
+1 -1
View File
@@ -29,7 +29,7 @@ export function Composer(props: {
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
modelControlsVisible={!props.model.model.loading}
variantControlVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
+12 -14
View File
@@ -42,7 +42,7 @@ export type ComposerEditorProps = {
borderUnderlay?: boolean
class?: string
modelControl?: JSX.Element
modelControlsVisible?: boolean
variantControlVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
}
@@ -233,19 +233,17 @@ export function ComposerEditor(props: ComposerEditorProps) {
/>
)}
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
)}
</Show>
{props.modelControl}
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
)}
</Show>
</div>
<ComposerEditorSubmitButton
+10 -72
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
@@ -70,8 +69,6 @@ function submitInput(
function session(input: {
calls: string[]
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
handoff?: ComposerSession["handoff"]
statuses?: ("idle" | "running")[]
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
@@ -80,7 +77,6 @@ function session(input: {
return {
id: "session-1",
directory: "C:/repo",
handoff: input.handoff,
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
@@ -96,7 +92,6 @@ function session(input: {
data: {
location: { command: { list: () => [] } },
session: {
setStatus: (_sessionID, status) => input.statuses?.push(status),
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
@@ -145,12 +140,10 @@ describe("Composer submission", () => {
test("starts and promotes a New Session once before admitting its first prompt", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
const promoted = createMemoryComposerState().capture()
const calls: string[] = []
const statuses: ("idle" | "running")[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const cleanupReady = Promise.withResolvers<void>()
const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) })
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
@@ -163,70 +156,18 @@ describe("Composer submission", () => {
async start(_selection, submission) {
calls.push("start")
submission.retarget(promoted)
return { session: target, cleanupReady: cleanupReady.promise }
},
}
const submitted = submitInput(adapter).submit(new Event("submit"))
const request = await admitted.promise
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"])
expect(statuses).toEqual(["running"])
expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }])
cleanupReady.resolve()
await submitted
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("first prompt")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("hands off image-only first prompts before admission", async () => {
const draft = createMemoryComposerState().capture()
draft.set([
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment",
filename: "image.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
const handedOff = Promise.withResolvers<SessionMessageUser>()
const target = session({
calls: [],
handoff: { set: handedOff.resolve, clear() {} },
prompt: async () => undefined,
})
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
return { session: target, cleanupReady: Promise.resolve() }
return target
},
}
await submitInput(adapter).submit(new Event("submit"))
const request = await admitted.promise
expect(await handedOff.promise).toMatchObject({
type: "user",
text: "",
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
name: "image.png",
},
],
})
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("first prompt")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
@@ -292,7 +233,7 @@ describe("Composer submission", () => {
submitted() {},
async start(_selection, submission) {
submission.retarget(promoted)
return { session: target, cleanupReady: Promise.resolve() }
return target
},
}
@@ -309,12 +250,10 @@ describe("Composer submission", () => {
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
throw new Error("network unavailable")
@@ -344,7 +283,6 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
})
+12 -58
View File
@@ -1,5 +1,4 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -66,43 +65,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
try {
const started =
const session =
input.adapter.kind === "active-session"
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
? input.adapter.session()
: await input.adapter.start(value.selection, submission)
if (!started) return
const session = started.session
if (!session) return
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
input.adapter.submitted()
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sending.then((result) => {
if (!result.ok)
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
})
})
return
}
await started.cleanupReady
input.adapter.submitted()
if (value.mode === "shell") {
@@ -111,6 +82,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
const command = findCommand(session, value.text)
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command).catch((error) =>
@@ -119,6 +91,14 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sendPrompt(session, value).catch((error) =>
failSubmission(input, session, "prompt", error, restore, value.id),
)
} finally {
submitting.delete(input.adapter.state)
}
@@ -130,29 +110,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
}
}
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
return {
id: value.id,
type: "user",
text: value.text,
files: value.images.map((image) => ({
data: "",
mime: image.mime,
source: { type: "uri", uri: image.blob.url },
name: image.sourcePath ?? image.filename,
})),
metadata: {
displayText: value.text,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
time: { created: Date.now() },
}
}
function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
@@ -356,11 +313,8 @@ function failSubmission(
error: unknown,
restore: () => boolean,
messageID?: string,
rollback?: () => void,
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff?.clear(messageID)
rollback?.()
restore()
input.notify.failed(kind, error)
}
-1
View File
@@ -1,7 +1,6 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
+1 -7
View File
@@ -42,10 +42,6 @@ const clearAuthToken = () => {
const web = createWebPlatform(pkg.version)
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true })
}
if (import.meta.env.VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.VITE_SENTRY_DSN,
@@ -65,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,
@@ -30,7 +30,6 @@ export function HomeCommandPalette(props: {
opened: server.projects.list,
stored: () => server.sync.data.project,
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -137,17 +137,6 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
lookup: async (sessionID: string) => {
const ctx = home.server.focusedContext()
if (!ctx) return
const result = await ctx.sdk.api.session.get({ sessionID })
if (result.time.archived) return
return buildHomeSessionRecords({
sessions: () => [result],
projectDirectories,
projects: home.project.list,
})[0]
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
+5 -35
View File
@@ -8,21 +8,13 @@ import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
import { looksLikeSessionID } from "@/session/search"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
const command = useCommand()
const language = useLanguage()
const [state, setState] = createStore({
value: "",
focused: false,
highlighted: "",
exact: undefined as HomeSessionRecord | undefined,
lookingUp: false,
})
let lookup = 0
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let list: HTMLDivElement | undefined
@@ -30,11 +22,9 @@ export function createHomeSessionSearchController(home: HomeController, sessions
const results = createMemo(() => {
const value = query().toLowerCase()
if (!value) return []
const records = sessions.data
return sessions.data
.searchRecords()
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
return [state.exact, ...records]
})
const active = createMemo(() => {
const records = results()
@@ -60,7 +50,6 @@ export function createHomeSessionSearchController(home: HomeController, sessions
close()
}),
)
onCleanup(() => lookup++)
command.register("home.search", () => [
{
@@ -78,26 +67,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
}
function close() {
lookup++
setState({ value: "", focused: false, exact: undefined, lookingUp: false })
}
function update(value: string) {
const current = ++lookup
const sessionID = value.trim()
setState({ value, highlighted: "", exact: undefined, lookingUp: false })
if (!looksLikeSessionID(sessionID)) return
setState("lookingUp", true)
void sessions.session.lookup(sessionID).then(
(record) => {
if (current !== lookup) return
setState({ exact: record, lookingUp: false })
},
() => {
if (current !== lookup) return
setState("lookingUp", false)
},
)
setState({ value: "", focused: false })
}
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
@@ -111,11 +81,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
placeholder,
open,
focus,
input: update,
input: (value: string) => setState({ value, highlighted: "" }),
close,
},
result: {
loading: () => sessions.data.loading() || state.lookingUp,
loading: sessions.data.loading,
list: results,
active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
+1 -1
View File
@@ -509,7 +509,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
<div
class={`
shrink-0 text-[13px] leading-text-compact tracking-[-0.04px]
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
text-v2-text-text-base [font-weight:530]
`}
>
-8
View File
@@ -17,14 +17,6 @@
font-style: normal;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("@ibm/plex/IBM-Plex-Mono/fonts/complete/woff2/IBMPlexMono-Text.woff2") format("woff2");
font-weight: 440;
font-style: normal;
font-display: swap;
}
@media (display-mode: standalone) {
/* WebKit excludes safe-area insets from dvh in installed apps. */
#root {
@@ -1,6 +1,5 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { startTransition } from "solid-js"
import type { NewSessionComposerAdapter } from "@/composer/adapter"
import { useComposerState } from "@/composer/persistence"
@@ -10,13 +9,11 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
export function createNewSessionComposerAdapter(props: {
draftID: string
@@ -56,35 +53,26 @@ export function createNewSessionComposerAdapter(props: {
})
if (!sessionDirectory) return
const created = data.session.create({
agent: selection.agent,
model: {
id: selection.model.modelID,
providerID: selection.model.providerID,
variant: selection.variant,
},
location: { directory: sessionDirectory },
})
const creation = created.request.then(
() => ({ ok: true as const }),
(error) => {
const created = await serverSDK.api.session
.create({
agent: selection.agent,
model: {
id: selection.model.modelID,
providerID: selection.model.providerID,
variant: selection.variant,
},
location: { directory: sessionDirectory },
})
.catch((error) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(language, error),
})
return { ok: false as const, error }
},
)
const afterCreation = async <T,>(run: () => Promise<T>) => {
const result = await creation
if (!result.ok) throw result.error
return run()
}
const sessionKey = SessionStateKey.from(
serverSDK.scope,
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
const cleanupReady = startTransition(() => {
})
if (!created) return
data.session.remember(created)
await startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
@@ -104,32 +92,13 @@ export function createNewSessionComposerAdapter(props: {
})
return {
cleanupReady,
session: {
id: created.id,
directory: sessionDirectory,
handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event),
api: {
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
},
data: {
location: data.location,
session: {
setStatus: data.session.setStatus,
prompt: (input) =>
data.session.prompt({
...input,
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
}),
},
},
current: () => data.session.get(created.id),
admitted: (messageID) =>
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
},
id: created.id,
directory: sessionDirectory,
api: serverSDK.api.session,
data,
current: () => data.session.get(created.id) ?? created,
admitted: (messageID) =>
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
}
},
}
@@ -142,27 +111,6 @@ export function createNewSessionComposerAdapter(props: {
}
}
function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
let unsubscribe: VoidFunction | undefined
return {
set(message: SessionMessageUser) {
unsubscribe?.()
setSessionMessageHandoff(key, message)
unsubscribe = event.on("session.inbox.enqueued", (item) => {
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, message.id)
})
},
clear(messageID: string) {
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, messageID)
},
}
}
async function resolveSessionDirectory(input: {
projectDirectory: string
worktree: string
+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>
+15 -10
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>
)
}
@@ -139,7 +144,7 @@ function ProviderTip() {
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={openProviders}
>
<span class="truncate">{language.t("home.providerTip")}</span>
@@ -190,7 +190,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
{(group) => (
<Show when={group.items().length > 0}>
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
{group.title}
</div>
<For each={group.items()}>
@@ -198,7 +198,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-text-compact tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
onMouseEnter={() => setStore("active", provider.id)}
disabled={store.connecting !== undefined}
+28 -54
View File
@@ -7,7 +7,6 @@ import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { popularProviders } from "@/providers/catalog/providers"
import { useLanguage } from "@/runtime/i18n/language"
@@ -24,7 +23,6 @@ export const DialogManageModels: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
@@ -122,38 +120,16 @@ export const DialogManageModels: Component = () => {
}
>
<For each={list.grouped.latest}>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<div class="settings-models-group-header justify-between">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
/>
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
{(group) => (
<div class="settings-section" data-component="settings-models-provider">
<div class="settings-models-group-header justify-between">
<div class="flex min-w-0 items-center gap-2">
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
<h3 class="settings-section-title">{group.items[0].provider.name}</h3>
</div>
<div>
<Switch
class="me-6"
class="mr-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
@@ -161,28 +137,26 @@ export const DialogManageModels: Component = () => {
{group.items[0].provider.name}
</Switch>
</div>
<Show when={expanded()}>
<SettingsList>
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
</Show>
</div>
)
}}
<SettingsList>
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
</div>
)}
</For>
</Show>
</Show>
+36 -7
View File
@@ -1,24 +1,44 @@
import { type Accessor, createMemo } from "solid-js"
import { type Accessor, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useProviders } from "@/providers/catalog/providers"
import { useGlobal } from "@/runtime/server/runtime"
import { Persist, persisted } from "@/runtime/persistence/storage"
export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
type Store = {
user: User[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}
const RECENT_LIMIT = 5
function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
}
const createModelsPersistedState = () => {
const [store, setStore, _, ready] = persisted(
Persist.global("model"),
createStore<Store>({
user: [],
recent: [],
variant: {},
}),
)
return [store, setStore, ready] as const
}
const createModelsController = (directory: Accessor<string | undefined>) => {
const providers = useProviders(() => directory())
const models = useGlobal().models
const store = models.store
const setStore = models.set
const [store, setStore, ready] = createModelsPersistedState()
const available = createMemo(() =>
providers.connected().flatMap((p) =>
@@ -128,14 +148,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
setStore("variant", key, value)
}
const [recentModels] = createResource(
async () => {
const recent = store.recent
await ready.promise
return recent
},
(p) => p,
{ initialValue: [] },
)
return {
ready: models.ready,
ready,
list,
find,
visible,
setVisibility,
recent: {
list: models.recent,
list: () => recentModels()!,
push,
},
variant: {
@@ -10,10 +10,9 @@ import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/d
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Menu } from "@opencode-ai/ui/menu"
import { TextInput } from "@opencode-ai/ui/text-input"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { decode64 } from "@/runtime/persistence/base64"
@@ -21,8 +20,6 @@ import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -47,182 +44,70 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
action?: JSX.Element
model?: ModelState
}> = (props) => {
const model = props.model ?? useLocal().model
const language = useLanguage()
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: props.onSelect,
})
const [store, setStore] = createStore({
search: "",
active: "",
collapsed: {} as Record<string, boolean>,
})
const models = createMemo(() => controller.models(store.search))
const groups = createMemo(() => controller.groups(models()))
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
let scrollRef: HTMLDivElement | undefined
const setSearch = (value: string) => {
const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id])
setStore({ search: value, active: first ? modelKey(first) : "" })
}
const moveActive = (delta: number) => {
const keys = visibleModels().map(modelKey)
if (keys.length === 0) return
const index = keys.indexOf(store.active)
const start = index === -1 ? (delta > 0 ? -1 : 0) : index
setStore("active", keys[(start + delta + keys.length) % keys.length])
queueMicrotask(() => {
scrollRef
?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`)
?.scrollIntoView({ block: "nearest" })
})
}
const selectActive = () => {
const item = visibleModels().find((item) => modelKey(item) === store.active)
if (item) controller.select(item)
}
const models = createMemo(() =>
model
.list()
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
<div class="relative">
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={store.search}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
/>
<List
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
itemWrapper={(item, node) => (
<Tooltip
appearance="standard"
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={i.latest}>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
>
<For each={groups()}>
{(group) => {
const searching = () => store.search.length > 0
const open = () => expanded(group.category)
return (
<section class="settings-section" data-expanded={open() ? "" : undefined}>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={open()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, open())}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList>
<For each={group.items}>
{(item) => (
<button
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
onMouseLeave={() => setStore("active", "")}
onClick={() => controller.select(item)}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
placement="right-start"
gutter={12}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={controller.current() === modelKey(item)}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)}
</For>
</SettingsList>
</Show>
</section>
)
}}
</For>
</Show>
</div>
</div>
</div>
)}
</List>
)
}
@@ -546,25 +431,18 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
}
return (
<Dialog size="large" variant="settings">
<DialogHeader hideClose closeLabel={language.t("common.close")}>
<Dialog>
<DialogHeader hideClose>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
<Button icon="plus" onClick={provider}>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
{language.t("command.provider.connect")}
</Button>
</DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<DialogBody>
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<div class="shrink-0 border-t border-v2-border-border-muted px-4 py-3">
<button
type="button"
class="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] font-[530] leading-text-compact 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"
onClick={manage}
>
<Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.manage")}</span>
</button>
</div>
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")}
</Button>
</DialogBody>
</Dialog>
)
+1 -19
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}}",
@@ -570,13 +565,10 @@ export const dict = {
"toast.update.action.notYet": "Not yet",
"error.page.title": "Something went wrong",
"error.page.title.status": "Server request failed",
"error.page.description": "An error occurred while loading the application.",
"error.page.description.status": "The server returned an HTTP {{status}} response.",
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.reload": "Reload",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.exportLogs": "Export Logs",
@@ -665,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",
@@ -925,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",
@@ -951,8 +935,6 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showProjectIcon.title": "Project icon",
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
+1 -35
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, createEffect, createMemo, createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
@@ -10,7 +10,6 @@ import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -25,7 +24,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const models = createGlobalModels()
const settingsServer = createMemo(() => {
const list = server.list
@@ -88,7 +86,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
},
},
models,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},
@@ -96,37 +93,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
function createGlobalModels() {
const [store, setStore, _, ready] = persisted(
Persist.global("model"),
createStore<{
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
recent: Array<{ providerID: string; modelID: string }>
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const [recent] = createResource(
async () => {
const value = store.recent
await ready.promise
return value
},
(value) => value,
{ initialValue: [] },
)
return {
store,
set: setStore,
ready,
recent: () => recent()!,
}
}
function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
+1 -1
View File
@@ -386,7 +386,7 @@
padding: 48px 24px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
@@ -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(),
@@ -16,7 +16,6 @@ export function createActiveComposerAdapter(input: {
if (!id) throw new Error("Active Composer requires a Session ID")
const prompt = useComposerState()
prompt.current()
const state = prompt.capture()
const data = useData()
const server = useServerSDK()
+8 -13
View File
@@ -101,10 +101,6 @@ export function createActiveSessionRegion(input: {
const focus = () => {
if (!input.session.data.isChild()) promptRef?.focus()
}
const openParent = () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
}
const editable = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
@@ -117,7 +113,6 @@ export function createActiveSessionRegion(input: {
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const active = activeElement()
@@ -129,11 +124,6 @@ export function createActiveSessionRegion(input: {
) {
return
}
if (event.key === "Escape" && input.session.data.isChild()) {
event.preventDefault()
openParent()
return
}
if (active === promptRef) {
if (event.key === "Escape") promptRef?.blur()
return
@@ -143,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
@@ -182,7 +172,10 @@ export function createActiveSessionRegion(input: {
},
region: {
centered: input.screen.centered,
openParent,
openParent: () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
},
prompt,
setDockRef: input.timeline.view.setDockRef,
setPromptRef: (element: HTMLDivElement) => {
@@ -211,7 +204,9 @@ export function ActiveSessionComposerRegion(props: {
}) {
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
sessionKey: props.session.identity.sessionKey,
sessionID: () => props.session.identity.params.id,
prompt: props.model.region.prompt,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
@@ -1,15 +1,47 @@
import type { Accessor } from "solid-js"
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
import type { useComposerState } from "@/composer/persistence"
import { useData } from "@/runtime/server/current"
import { getSessionHandoff, setSessionHandoff } from "@/session/handoff"
import type { SessionRequestModel } from "../requests/model"
export function createSessionComposerRegionController(input: {
state: SessionRequestModel
parentID: Accessor<string | undefined>
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
prompt: ReturnType<typeof useComposerState>
centered: Accessor<boolean>
onResponseSubmit: () => void
openParent: () => void
setPromptRef: (el: HTMLDivElement) => void
setDockRef: (el: HTMLDivElement) => void
}) {
const data = useData()
createEffect(() => {
if (!input.prompt.ready()) return
setSessionHandoff(input.sessionKey(), {
prompt: input.prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim(),
})
})
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? data.session.get(id)?.parentID : undefined
})
const ready = Promise.resolve()
const [promptReady] = createResource(
() => input.prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return {
state: input.state,
centered: input.centered,
@@ -17,9 +49,11 @@ export function createSessionComposerRegionController(input: {
openParent: input.openParent,
setPromptRef: input.setPromptRef,
setDockRef: input.setDockRef,
parentID: input.parentID,
child: () => !!input.parentID(),
showComposer: () => !input.state.blocked() || !!input.parentID(),
parentID,
child: () => !!parentID(),
showComposer: () => !input.state.blocked() || !!parentID(),
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
promptReady: () => input.prompt.ready() || promptReady(),
}
}
@@ -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,
@@ -19,6 +22,8 @@ export type SessionComposerRegionViewController = Pick<
| "parentID"
| "child"
| "showComposer"
| "handoffPrompt"
| "promptReady"
> & { state: SessionComposerRegionState }
export function SessionComposerRegion(props: {
@@ -27,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}
@@ -63,32 +71,55 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={controller.showComposer()}>
<div
classList={{
"relative z-[70]": true,
}}
<Show
when={controller.promptReady()}
fallback={
<>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
}
>
<Show
when={controller.child()}
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
>
<div
ref={controller.setPromptRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
<Show when={background()}>
<div>
<SessionBackgroundDock
blocking={controller.state.background.blocking()}
tasks={controller.state.background.tasks()}
onBackground={() => void controller.state.background.move()}
/>
</div>
</Show>
</div>
<div
classList={{
"relative z-[70]": true,
}}
style={{
"margin-top": `${background() ? -36 : 0}px`,
}}
>
<Show
when={controller.child()}
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
>
<div
ref={controller.setPromptRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</div>
</Show>
</div>
</Show>
</Show>
</div>
</div>
-18
View File
@@ -1,18 +0,0 @@
import { expect, test } from "bun:test"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { clearSessionMessageHandoff, getSessionMessageHandoff, setSessionMessageHandoff } from "./handoff"
test("stores and clears a message handoff", () => {
const message = {
id: "msg_handoff",
type: "user",
text: "",
files: [{ data: "", mime: "image/png", source: { type: "uri", uri: "blob:image" } }],
time: { created: 1 },
} satisfies SessionMessageUser
setSessionMessageHandoff("session-key", message)
expect(getSessionMessageHandoff("session-key")).toEqual(message)
clearSessionMessageHandoff("session-key", message.id)
expect(getSessionMessageHandoff("session-key")).toBeUndefined()
})
+2 -25
View File
@@ -1,8 +1,7 @@
import type { SelectedLineRange } from "@/workspaces/files/model"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
@@ -12,8 +11,6 @@ const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const [messages, setMessages] = createStore<Record<string, SessionMessageUser | undefined>>({})
const messageOrder = new Map<string, true>()
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
@@ -26,32 +23,12 @@ const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
}
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = store.session.get(key) ?? { files: {} }
const prev = store.session.get(key) ?? { prompt: "", files: {} }
touch(store.session, key, { ...prev, ...patch })
}
export const getSessionHandoff = (key: string) => store.session.get(key)
export const setSessionMessageHandoff = (key: string, message: SessionMessageUser) => {
messageOrder.delete(key)
messageOrder.set(key, true)
setMessages(key, message)
while (messageOrder.size > MAX) {
const first = messageOrder.keys().next().value
if (first === undefined) return
messageOrder.delete(first)
setMessages(first, undefined)
}
}
export const getSessionMessageHandoff = (key: string) => messages[key]
export const clearSessionMessageHandoff = (key: string, messageID: string) => {
if (messages[key]?.id !== messageID) return
messageOrder.delete(key)
setMessages(key, undefined)
}
export const setTerminalHandoff = (key: string, value: string[]) => {
touch(store.terminal, key, value)
}
@@ -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>
)
}
+1 -14
View File
@@ -15,8 +15,6 @@ import {
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@@ -25,8 +23,6 @@ const idle = { type: "idle" as const }
export function useSessionModel() {
const file = useFile()
const data = useData()
const server = useServer()
const shellTabs = useTabs()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -35,16 +31,7 @@ export function useSessionModel() {
const id = sessionID()
return id ? data.session.get(id) : undefined
})
const parentID = createMemo(() => {
const current = info()?.parentID
if (current) return current
const id = sessionID()
if (!id) return
const tab = shellTabs.store.find(
(item) => item.type === "session" && item.server === server.key && item.routeSessionId === id,
)
return tab?.type === "session" ? (tab.routeParentId ?? tab.sessionId) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
return id ? data.session.get(id) : undefined
@@ -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>
)
}
+16 -29
View File
@@ -15,8 +15,7 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import { useTabs } from "@/shell/tabs/tabs"
import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
import { SessionPanelFrame } from "./session-frame"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionPanelFrame, SessionRouteFrame } from "./session-frame"
import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
@@ -32,7 +31,7 @@ export function TargetSessionRouteContent() {
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</ModelsProvider>
@@ -46,14 +45,16 @@ function TargetSessionSettingsCommand() {
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
return (
<ErrorBoundary
fallback={(error) => (
<SessionStatePanel>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionStatePanel>
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
)}
>
{props.children}
@@ -77,14 +78,16 @@ function ResolvedTargetSessionRoute() {
<Show
when={!server.health?.incompatible}
fallback={
<SessionStatePanel>
<IncompatibleServerPanel
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionStatePanel>
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<IncompatibleServerPanel
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionPanelFrame>
</SessionRouteFrame>
}
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
<Show when={directory()}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
@@ -97,22 +100,6 @@ function ResolvedTargetSessionRoute() {
)
}
function PendingSessionState(props: { sessionID: string }) {
return (
<SessionStatePanel>
<SessionIdentityHeader sessionID={props.sessionID} />
</SessionStatePanel>
)
}
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 p-2">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
}
function TargetSessionPage() {
const location = useWorkspaceLocation()
const server = useServerSDK()
+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,
},
}
+61 -103
View File
@@ -8,7 +8,7 @@ import { useSettings } from "@/settings/model"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame } from "@/session/session-frame"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { TerminalPanel } from "@/session/terminal/panel"
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
import { SessionErrorFallback } from "./route-error"
@@ -17,7 +17,6 @@ import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
@@ -59,52 +58,39 @@ export function SessionScreen(props: { session: SessionModel }) {
const sessionPanelContent = () => (
<>
{timeline.resource() ?? ""}
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
<SessionMobileTabs review={review} compact />
</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
{(error) => {
throw error()
}}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={session.identity.params.id && review.mobile.changes()}>
<SessionMobileReview review={review} />
</Match>
<Match when={session.identity.params.id}>
<Show when={!messagesReady()}>
<SessionIdentityHeader
sessionID={session.identity.params.id ?? ""}
session={session.data.info()}
/>
</Show>
<Show
when={messagesReady() ? session.identity.params.id : undefined}
keyed
>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_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}
@@ -132,90 +118,53 @@ 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"
@@ -231,10 +180,19 @@ 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>
</>
</SessionRouteFrame>
)
}
-3
View File
@@ -1,3 +0,0 @@
export function looksLikeSessionID(value: string) {
return value.length > 20 && value.startsWith("ses_")
}
@@ -1,145 +0,0 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "./title"
export function SessionTitleHeader(props: ParentProps) {
return (
<div
data-session-title
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
>
{props.children}
</div>
)
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const navigate = useNavigate()
const tab = createMemo(() =>
tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
),
)
const info = createMemo(() => {
const current = tab()
return current ? tabs.info[tabKey(current)] : undefined
})
const parentID = createMemo(() => {
if (props.session?.parentID) return props.session.parentID
const current = tab()
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
return current.routeParentId ?? current.sessionId
})
const parent = createMemo(() => {
const id = parentID()
return id ? server.ctx.data.session.get(id) : undefined
})
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
})
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
const value = directory()
if (!value) return undefined
const key = pathKey(value)
return projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
})
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
const navigateParent = () => {
const id = parentID()
const current = tab()
if (!id || current?.type !== "session") return
tabs.rememberSessionRoute(current, id, parent()?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<Show when={title() || parentTitle() || showProjectIcon()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center">
<span
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
}}
>
<Show
when={showProjectIcon()}
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
>
<ProjectAvatar
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
src={getProjectAvatarSource(project()?.id, project()?.icon)}
variant={getProjectAvatarVariant(project()?.icon?.color)}
/>
</Show>
</span>
<Show when={parentTitle()}>
{(value) => (
<button
type="button"
data-slot="session-title-parent"
dir="auto"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{value()}
</button>
)}
</Show>
<Show when={parentTitle() && title()}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
)}
</Show>
</div>
</div>
</div>
</SessionTitleHeader>
</Show>
)
}
@@ -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
+10
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,
@@ -208,6 +216,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
parentID: () => props.child?.parentID,
child: () => !!props.child,
showComposer: () => true,
handoffPrompt: () => undefined,
promptReady: () => true,
} satisfies SessionComposerRegionViewController
return (
@@ -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"
}
+1 -19
View File
@@ -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"
@@ -221,14 +220,6 @@ export const Terminal = (props: TerminalProps) => {
let drop: VoidFunction | undefined
let reconn: ReturnType<typeof setTimeout> | undefined
let tries = 0
let revealed = false
const reveal = () => {
if (revealed) return
if (!serializeAddon.serializeAsText({ trimWhitespace: true })) return
revealed = true
container.style.opacity = "1"
}
const cleanup = () => {
if (!cleanups.length) return
@@ -417,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
@@ -508,7 +493,6 @@ export const Terminal = (props: TerminalProps) => {
if (restore && restoreSize) {
await write(restore)
reveal()
fit.fit()
scheduleSize(t.cols, t.rows)
if (scrollY !== undefined) t.scrollToLine(scrollY)
@@ -518,7 +502,6 @@ export const Terminal = (props: TerminalProps) => {
scheduleSize(t.cols, t.rows)
if (restore) {
await write(restore)
reveal()
if (scrollY !== undefined) t.scrollToLine(scrollY)
}
startResize()
@@ -615,7 +598,6 @@ export const Terminal = (props: TerminalProps) => {
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
if (!revealed) output?.flush(reveal)
cursor += data.length
seek = cursor
}
@@ -696,7 +678,7 @@ export const Terminal = (props: TerminalProps) => {
dir="ltr"
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background, "caret-color": "transparent", opacity: 0 }}
style={{ "background-color": terminalColors().background }}
classList={{
...local.classList,
"select-text": 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,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { visibleTimelineMessages } from "./controller-projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -41,37 +41,3 @@ describe("visibleTimelineMessages", () => {
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
})
})
describe("applyTimelineMessageHandoff", () => {
const handoff = {
id: "msg_image",
type: "user",
text: "",
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "blob:image" },
name: "image.png",
},
],
time: { created: 1 },
} satisfies SessionMessageInfo
test("shows a promoted image-only prompt before client admission", () => {
expect(applyTimelineMessageHandoff([], handoff)).toEqual([handoff])
})
test("adds attachments to the client's optimistic row", () => {
const optimistic = { id: handoff.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([optimistic], handoff)).toEqual([{ ...optimistic, files: handoff.files }])
})
test("keeps the durable attachment payload", () => {
const durable = {
...handoff,
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
})
})
@@ -1,13 +1,4 @@
import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
if (!handoff) return messages
const index = messages.findIndex((message) => message.id === handoff.id)
if (index < 0) return [...messages, handoff]
const message = messages[index]
if (message.type !== "user" || message.files?.length) return messages
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
}
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
export function visibleTimelineMessages(
messages: SessionMessageInfo[],
@@ -16,15 +16,9 @@ import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import {
applyTimelineMessageHandoff,
timelineChildTitle,
timelineRemovedSessionIDs,
visibleTimelineMessages,
} from "./controller-projection"
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
@@ -58,16 +52,10 @@ export function createTimelineController(input: { session: TimelineSessionSource
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
getSessionMessageHandoff(input.session.identity.sessionKey()),
),
)
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
return visibleTimelineMessages(
handedOffMessages(),
input.session.history.messages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
@@ -1,4 +1,5 @@
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, on, onCleanup } from "solid-js"
@@ -15,31 +16,23 @@ 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,
},
follow: {
sessionKey: session.identity.sessionKey(),
pinned: true,
},
refs: {
content: undefined as HTMLDivElement | undefined,
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 = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: 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
@@ -109,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()
},
},
@@ -124,7 +117,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
})
const resume = () => {
setState("messageID", undefined)
pin()
autoScroll.resume()
scrollToEnd()
clearMessageHash()
if (scroller) scheduleScrollState(scroller)
@@ -140,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()
@@ -167,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
@@ -183,7 +177,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (
historyRequests.has(session.ownership.key()) ||
timeline.history.loading() ||
pinned() ||
!autoScroll.userScrolled() ||
!scroller ||
scroller.scrollTop >= 200
)
@@ -195,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()
})
@@ -213,10 +207,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
on(
session.identity.sessionKey,
() => {
pin()
setState("messageID", undefined)
setState("pendingMessage", undefined)
setState("scroll", { overflow: false, jump: false })
},
{ defer: true },
),
@@ -226,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()
},
@@ -250,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 },
),
@@ -273,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()
@@ -293,6 +285,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resume,
setActiveMessage,
},
autoScroll,
lastUserMessage: timeline.lastUserMessage,
resource: timeline.resource,
ready: timeline.ready,
@@ -300,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
},
@@ -320,7 +319,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setScrollToEnd: (scroll: () => void) => {
scrollToEnd = scroll
},
unpin,
shouldAnchorBottom,
},
}
}

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