mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 01:56:21 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e2eef182 | ||
|
|
282b644cde | ||
|
|
4fee1bcf3c | ||
|
|
da850f18da | ||
|
|
9a716609c6 | ||
|
|
917051a881 | ||
|
|
55f490b898 | ||
|
|
b190b778d4 | ||
|
|
95235df4c8 | ||
|
|
c3f21fb5fb | ||
|
|
f282a22d98 | ||
|
|
a859d48b08 | ||
|
|
1f60ad6e2f | ||
|
|
bdec7a4f87 | ||
|
|
d35f81ef92 | ||
|
|
d0d0ada4a9 | ||
|
|
beb6216343 | ||
|
|
d9c9c50809 | ||
|
|
a7106ae023 | ||
|
|
2ae3b82c24 | ||
|
|
784042202e | ||
|
|
320da4256d | ||
|
|
2bbaeb0d4b | ||
|
|
ada4f1bee6 |
@@ -27,7 +27,6 @@ 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
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: canary # Bun 1.4 until its stable release is published
|
||||
bun-version: 1.4.0
|
||||
|
||||
- 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: canary
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-xRvq8FkSjn+1q+1wcab+jAmQJdo8lHF8OGnzU+xPLyI=",
|
||||
"aarch64-linux": "sha256-uAwwtOz81LLimTqiDH6E1W65srSMLGk1QV0cUJYanj0=",
|
||||
"aarch64-darwin": "sha256-F28kZvtYRb+ri5T8JoIG/VMK6J7ad5R3QivwJDLtmWA=",
|
||||
"x86_64-darwin": "sha256-NWNoBjwQTTNAESdFWItT4UYc+fTLD1RIe/Ibbqtem7Q="
|
||||
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
|
||||
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
|
||||
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
|
||||
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@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.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ 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,6 +10,7 @@ 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),
|
||||
@@ -25,11 +26,6 @@ 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 {
|
||||
@@ -158,7 +154,13 @@ 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: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
driver: OpenResponsesContinuation.driver({
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
}),
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { Effect, Option, Schema } from "effect"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "openai-responses.websocket.v1"
|
||||
const PROTOCOL = "open-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 OpenAIResponsesChannel = { driver } as const
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
@@ -42,8 +42,12 @@ const OpenResponsesInputImage = Schema.Struct({
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
file_data: Schema.String,
|
||||
mime_type: Schema.optional(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,
|
||||
})
|
||||
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
@@ -54,7 +58,7 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
@@ -81,6 +85,7 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
|
||||
OpenResponsesInputText,
|
||||
OpenResponsesInputImage,
|
||||
OpenResponsesInputFile,
|
||||
OpenResponsesInputVideo,
|
||||
])
|
||||
|
||||
const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
@@ -323,7 +328,6 @@ 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 }
|
||||
@@ -422,18 +426,25 @@ 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"),
|
||||
file_data: media.dataUrl,
|
||||
...(url ? { file_url: url } : { file_data: media.base64 }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
@@ -442,10 +453,17 @@ 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* lowerMedia(part, request, extension)
|
||||
if (part.type === "media") return yield* lowerMessageMedia(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* (
|
||||
@@ -458,6 +476,20 @@ 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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -510,7 +542,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, extension) : undefined
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : 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] })
|
||||
@@ -567,7 +599,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
})
|
||||
}
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
@@ -1159,15 +1193,15 @@ 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),
|
||||
messagePhase,
|
||||
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)
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
|
||||
@@ -5,14 +5,13 @@ 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 { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { 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, type Options } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -40,20 +39,8 @@ 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),
|
||||
}
|
||||
@@ -67,16 +54,6 @@ 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) => {
|
||||
@@ -128,46 +105,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
} satisfies OpenAIResponsesBody
|
||||
})
|
||||
|
||||
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 hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
@@ -188,32 +126,22 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
})
|
||||
|
||||
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 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 step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
@@ -224,8 +152,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 && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -247,11 +175,7 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<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 channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
|
||||
export const transport = channelTransport({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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"
|
||||
@@ -0,0 +1,55 @@
|
||||
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"
|
||||
@@ -5,7 +5,8 @@ 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 * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
@@ -34,9 +35,9 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
protocol: XAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
transport: OpenResponsesChannel.transport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
@@ -14,7 +21,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\":\"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}"
|
||||
"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}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
@@ -14,7 +20,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"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}"
|
||||
"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}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"name": "pdf/xai-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:43.608Z"
|
||||
"recordedAt": "2026-07-22T18:15:43.608Z",
|
||||
"protocol": "xai-responses"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -14,7 +22,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\",\"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}"
|
||||
"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}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"name": "pdf/xai-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:42.429Z"
|
||||
"recordedAt": "2026-07-22T18:15:42.429Z",
|
||||
"protocol": "xai-responses"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -14,7 +21,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\",\"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}"
|
||||
"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}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -113,7 +113,9 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
input: [
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
|
||||
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.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 OpenAIResponsesChannel.driver({
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
@@ -1273,7 +1273,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1300,12 +1300,12 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: base64 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||
it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1334,7 +1334,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1359,7 +1358,61 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
{ 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" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2712,7 +2765,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2720,7 +2773,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
||||
it.effect("uses standard inline file encoding for xAI user PDFs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -2744,7 +2797,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2769,7 +2821,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
file_data: "AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2777,6 +2829,37 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers remote user media URLs without base64 wrapping", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "https://example.com/report.pdf",
|
||||
filename: "report.pdf",
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed rate limit for provider error frames", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
|
||||
id: "xai",
|
||||
name: "xAI Grok 4.5",
|
||||
provider: "xai",
|
||||
protocol: "openai-responses",
|
||||
protocol: "xai-responses",
|
||||
requires: "XAI_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 40,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
+49
-6
@@ -18,7 +18,7 @@ const branchDiffs = [
|
||||
),
|
||||
]
|
||||
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test("uses side placement by default and supports the terminal across the bottom", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -27,7 +27,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-terminal-stacked",
|
||||
name: "review-terminal-bottom",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
@@ -45,7 +45,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-terminal-stacked",
|
||||
slug: "review-terminal-bottom",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
@@ -138,7 +138,22 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
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)
|
||||
})
|
||||
|
||||
async function expectTree(page: Page, total: number, file: string) {
|
||||
@@ -163,23 +178,51 @@ async function expectMountedTree(page: Page, total: number) {
|
||||
expect(state.rows).toBeLessThanOrEqual(60)
|
||||
}
|
||||
|
||||
async function expectStackGeometry(page: Page) {
|
||||
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) {
|
||||
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: terminal.getBoundingClientRect().height,
|
||||
terminal: terminalRect.height,
|
||||
terminalLeft: terminalRect.left,
|
||||
terminalRight: terminalRect.right,
|
||||
terminalTop: terminalRect.top,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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-slot="collapsible-trigger"]').first().click()
|
||||
await wrapper.locator('[data-scope="apply-patch"] button').click()
|
||||
await expectExpanded(wrapper, false)
|
||||
|
||||
events.push(...textEvents())
|
||||
@@ -179,8 +179,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const diff = wrapper.locator('[data-component="edit-content"]').first()
|
||||
const trigger = wrapper.locator('[data-component="sticky-accordion-header"]')
|
||||
const diff = wrapper.locator('[data-component="apply-patch-file-diff"]').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-slot="collapsible-trigger"]')!
|
||||
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
|
||||
const trigger = element.querySelector<HTMLElement>('[data-component="sticky-accordion-header"]')!
|
||||
const diff = element.querySelector<HTMLElement>('[data-component="apply-patch-file-diff"]')!
|
||||
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).toBeLessThan(samples[0]!.diff)
|
||||
expect(samples[0]!.trigger).toBeGreaterThanOrEqual(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,7 +234,9 @@ async function readToolState(page: Page) {
|
||||
.evaluate(
|
||||
(element, textPartID) => ({
|
||||
expanded: (() => {
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const trigger =
|
||||
element.querySelector('[data-scope="apply-patch"] button') ??
|
||||
element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
if (aria === "true") return true
|
||||
if (aria === "false") return false
|
||||
@@ -409,7 +411,9 @@ function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const trigger =
|
||||
element.querySelector('[data-scope="apply-patch"] button') ??
|
||||
element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
if (aria === "true") return true
|
||||
if (aria === "false") return false
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
@@ -49,8 +56,101 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const file = wrapper.locator('[data-scope="apply-patch"]')
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("keeps an expanded file diff header at the same viewport position", async ({ page }) => {
|
||||
const id = "prt_file_projection_anchored_patch"
|
||||
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
||||
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update src/anchored.ts" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/anchored.ts",
|
||||
status: "modified",
|
||||
patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after),
|
||||
additions: 80,
|
||||
deletions: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
|
||||
]),
|
||||
],
|
||||
viewport: { width: 1200, height: 600 },
|
||||
})
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const trigger = wrapper.getByRole("button")
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
|
||||
})
|
||||
await expect(trigger).toBeInViewport()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
|
||||
await trigger.dispatchEvent("pointerdown")
|
||||
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
await trigger.dispatchEvent("click")
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
|
||||
.toBeLessThanOrEqual(5)
|
||||
|
||||
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await timeline.settle(40)
|
||||
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(50)
|
||||
expect(scrolled).toBeLessThan(400)
|
||||
|
||||
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY)
|
||||
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -84,11 +84,11 @@ test("transitions a streaming shell from writing through command execution", asy
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(subtitle).toHaveText(command)
|
||||
})
|
||||
|
||||
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
||||
test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
const id = "prt_shell_running_command"
|
||||
const command = "sleep 10 && echo done"
|
||||
await setupTimeline(page, {
|
||||
@@ -97,7 +97,7 @@ test("shows and expands a running shell command without shimmering it", async ({
|
||||
})
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
status,
|
||||
toolPart,
|
||||
@@ -76,36 +77,119 @@ test.describe("session timeline projection", () => {
|
||||
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 into one file group", async ({ page }) => {
|
||||
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
await setupTimeline(page, {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(first, "patch", "completed", { patchText: "Update src/first.ts" }, {
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
}),
|
||||
toolPart(second, "patch", "completed", { patchText: "Update src/second.ts" }, {
|
||||
metadata: { files: [patchFile("src/second.ts", "added")] },
|
||||
}),
|
||||
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).toBeVisible()
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByRole("button", { name: "Patch 2 files" })).toHaveCount(0)
|
||||
await expect(group.getByRole("button")).toHaveCount(2)
|
||||
await expect(group.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(2)
|
||||
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(
|
||||
[
|
||||
|
||||
@@ -9,10 +9,12 @@ const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const ptyInput: string[] = []
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
ptyInput.length = 0
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -70,7 +72,22 @@ 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`), () => undefined)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||
ws.onMessage((message) => ptyInput.push(message.toString()))
|
||||
})
|
||||
})
|
||||
|
||||
test("clears the terminal line with Command+Delete", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
|
||||
await page.keyboard.press("Meta+Backspace")
|
||||
|
||||
await expect.poll(() => ptyInput.join("")).toBe("\x15")
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -15,3 +15,14 @@
|
||||
|
||||
/*.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
|
||||
|
||||
@@ -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 } from "solid-js"
|
||||
import { createEffect, createMemo, startTransition } from "solid-js"
|
||||
|
||||
export function createHomeController() {
|
||||
const layout = useLayout()
|
||||
@@ -50,7 +50,8 @@ export function createHomeController() {
|
||||
selection: {
|
||||
value: selection,
|
||||
set: setSelection,
|
||||
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
|
||||
focusServer: (conn: ServerConnection.Any) =>
|
||||
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
|
||||
},
|
||||
server: {
|
||||
list: global.servers.list,
|
||||
|
||||
@@ -922,6 +922,10 @@ 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",
|
||||
|
||||
@@ -133,7 +133,7 @@ export function createActiveSessionRegion(input: {
|
||||
const scroller = input.timeline.scroller()
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
|
||||
input.timeline.view.markGesture(scroller)
|
||||
input.timeline.view.markUserScroll(scroller)
|
||||
return
|
||||
}
|
||||
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
|
||||
|
||||
@@ -15,6 +15,10 @@ 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() &&
|
||||
@@ -23,7 +27,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const resizable = createMemo(() => reviewPanelOpen() || desktopTerminalOpen())
|
||||
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
|
||||
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
|
||||
const [rowWidth, setRowWidth] = createSignal<number>()
|
||||
let row: HTMLDivElement | undefined
|
||||
@@ -57,7 +61,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
const panelLayout = createMemo(() =>
|
||||
sessionPanelLayout({
|
||||
review: reviewPanelOpen(),
|
||||
terminal: desktopTerminalOpen(),
|
||||
terminal: sideTerminalOpen(),
|
||||
files: fileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
@@ -97,11 +101,11 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
panelOpen: reviewPanelOpen,
|
||||
snap: reviewSnap,
|
||||
},
|
||||
side: { layout: panelLayout, open: sidePanelOpen },
|
||||
side: { layout: panelLayout },
|
||||
size,
|
||||
terminal: {
|
||||
desktopOpen: desktopTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => desktopTerminalOpen() && !reviewPanelOpen()),
|
||||
bottomOpen: bottomTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
|
||||
open: terminalOpen,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,20 +78,18 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
setScrollRef={timeline.view.setScrollRef}
|
||||
onScheduleScrollState={timeline.view.scheduleScrollState}
|
||||
onAutoScrollHandleScroll={timeline.autoScroll.handleScroll}
|
||||
onMarkScrollGesture={timeline.view.markGesture}
|
||||
hasScrollGesture={timeline.view.hasGesture()}
|
||||
onPin={timeline.view.pin}
|
||||
onUnpin={timeline.view.unpin}
|
||||
onUserScroll={timeline.view.markUserScroll}
|
||||
onHistoryScroll={timeline.view.onHistoryScroll}
|
||||
onAutoScrollInteraction={timeline.autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={timeline.view.shouldAnchorBottom()}
|
||||
onSelectionInteraction={timeline.view.selectionInteraction}
|
||||
pinned={timeline.view.pinned()}
|
||||
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}
|
||||
@@ -121,51 +119,88 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return (
|
||||
<SessionRouteFrame>
|
||||
<SessionHeader />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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()}>
|
||||
<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()}
|
||||
>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
@@ -181,16 +216,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<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>
|
||||
<TerminalPanel stacked={isDesktop()} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export function terminalKeyInput(event: KeyboardEvent) {
|
||||
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "backspace") return
|
||||
return "\x15"
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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"
|
||||
@@ -408,6 +409,12 @@ export const Terminal = (props: TerminalProps) => {
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
const input = terminalKeyInput(event)
|
||||
if (input) {
|
||||
t.input(input, true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
|
||||
document.execCommand("copy")
|
||||
return true
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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"
|
||||
import { createEffect, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import type { SessionModel } from "../model"
|
||||
@@ -16,7 +15,6 @@ 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,
|
||||
@@ -26,13 +24,18 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
dock: undefined as HTMLDivElement | undefined,
|
||||
},
|
||||
})
|
||||
const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "none" })
|
||||
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
|
||||
// it from scroll geometry; everything else only expresses explicit intent.
|
||||
const [pinned, setPinned] = createSignal(true)
|
||||
const pin = () => setPinned(true)
|
||||
const unpin = () => {
|
||||
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
|
||||
setPinned(false)
|
||||
}
|
||||
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
|
||||
@@ -102,10 +105,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pendingMessage: () => state.pendingMessage,
|
||||
setPendingMessage: (value) => setState("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
autoScroll: {
|
||||
pause: autoScroll.pause,
|
||||
forceScrollToBottom: () => {
|
||||
autoScroll.resume()
|
||||
follow: {
|
||||
unpin,
|
||||
toBottom: () => {
|
||||
pin()
|
||||
scrollToEnd()
|
||||
},
|
||||
},
|
||||
@@ -117,7 +120,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
})
|
||||
const resume = () => {
|
||||
setState("messageID", undefined)
|
||||
autoScroll.resume()
|
||||
pin()
|
||||
scrollToEnd()
|
||||
clearMessageHash()
|
||||
if (scroller) scheduleScrollState(scroller)
|
||||
@@ -133,21 +136,23 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resume()
|
||||
return
|
||||
}
|
||||
autoScroll.pause()
|
||||
unpin()
|
||||
scrollToMessage(messages[target], "auto")
|
||||
}
|
||||
const shouldAnchorBottom = () =>
|
||||
!location.hash && !state.messageID && !state.pendingMessage && !autoScroll.userScrolled()
|
||||
const markGesture = (target?: EventTarget | null) => {
|
||||
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
|
||||
const markUserScroll = (target?: EventTarget | null) => {
|
||||
if (!scroller) return
|
||||
const element = target instanceof Element ? target : undefined
|
||||
const nested = element?.closest("[data-scrollable]")
|
||||
if (nested && nested !== scroller) return
|
||||
setState("gestureAt", Date.now())
|
||||
scrollMark += 1
|
||||
}
|
||||
const selectionInteraction = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.toString().length > 0) unpin()
|
||||
}
|
||||
const setScrollRef = (element: HTMLDivElement | undefined) => {
|
||||
scroller = element
|
||||
autoScroll.scrollRef(element)
|
||||
if (!element) return
|
||||
scheduleScrollState(element)
|
||||
fill()
|
||||
@@ -158,15 +163,12 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
historyRequests.add(owner.key)
|
||||
const before = timeline.messages().length
|
||||
try {
|
||||
await timeline.history.loadOlder({
|
||||
before: () => owner.run(captureHistoryAnchor),
|
||||
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
|
||||
})
|
||||
await timeline.history.loadOlder()
|
||||
} finally {
|
||||
historyRequests.delete(owner.key)
|
||||
}
|
||||
if (!owner.current() || timeline.messages().length <= before) return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
|
||||
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
@@ -177,7 +179,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
if (
|
||||
historyRequests.has(session.ownership.key()) ||
|
||||
timeline.history.loading() ||
|
||||
!autoScroll.userScrolled() ||
|
||||
pinned() ||
|
||||
!scroller ||
|
||||
scroller.scrollTop >= 200
|
||||
)
|
||||
@@ -189,7 +191,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
fillFrame = requestAnimationFrame(() => {
|
||||
fillFrame = undefined
|
||||
if (!session.identity.params.id || !timeline.ready()) return
|
||||
if (autoScroll.userScrolled() || timeline.history.loading() || !scroller) return
|
||||
if (!pinned() || timeline.history.loading() || !scroller) return
|
||||
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
|
||||
void loadOlder()
|
||||
})
|
||||
@@ -218,15 +220,16 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
() => session.identity.params.id,
|
||||
(id, previous) => {
|
||||
if (!id || !previous || id === previous || state.messageID || state.pendingMessage || location.hash) return
|
||||
autoScroll.resume()
|
||||
pin()
|
||||
scrollToEnd()
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
autoScroll.userScrolled,
|
||||
(scrolled) => {
|
||||
if (scrolled) return
|
||||
pinned,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
setState("messageID", undefined)
|
||||
clearMessageHash()
|
||||
},
|
||||
@@ -241,11 +244,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
timeline.ready(),
|
||||
timeline.history.more(),
|
||||
timeline.history.loading(),
|
||||
autoScroll.userScrolled(),
|
||||
pinned(),
|
||||
visibleUserMessages().length,
|
||||
] as const,
|
||||
([id, ready, more, loading, scrolled]) => {
|
||||
if (id && ready && more && !loading && !scrolled) fill()
|
||||
([id, ready, more, loading, following]) => {
|
||||
if (id && ready && more && !loading && following) fill()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -264,8 +267,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
if (next === dockHeight) return
|
||||
const delta = next - dockHeight
|
||||
const stick = scroller
|
||||
? !autoScroll.userScrolled() ||
|
||||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
|
||||
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
dockHeight = next
|
||||
if (stick) scrollToEnd()
|
||||
@@ -285,7 +287,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
autoScroll,
|
||||
lastUserMessage: timeline.lastUserMessage,
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
@@ -293,25 +294,19 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
scroller: () => scroller,
|
||||
view: {
|
||||
anchor,
|
||||
hasGesture: () => Date.now() - state.gestureAt < 250,
|
||||
markGesture,
|
||||
markUserScroll: () => {
|
||||
scrollMark += 1
|
||||
},
|
||||
markUserScroll,
|
||||
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
|
||||
},
|
||||
@@ -319,7 +314,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setScrollToEnd: (scroll: () => void) => {
|
||||
scrollToEnd = scroll
|
||||
},
|
||||
shouldAnchorBottom,
|
||||
unpin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
|
||||
test("does not measure an element detached before the frame", async () => {
|
||||
const element = document.createElement("div")
|
||||
document.body.append(element)
|
||||
let calls = 0
|
||||
|
||||
scheduleConnectedMeasure(element, () => {
|
||||
calls += 1
|
||||
})
|
||||
element.remove()
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
test("measures a connected element on the next frame", async () => {
|
||||
const element = document.createElement("div")
|
||||
document.body.append(element)
|
||||
let calls = 0
|
||||
|
||||
scheduleConnectedMeasure(element, () => {
|
||||
calls += 1
|
||||
})
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
element.remove()
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
|
||||
return requestAnimationFrame(() => {
|
||||
if (element.isConnected) measure(element)
|
||||
})
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function BackgroundMoveHint(props: { keybind?: string[] }) {
|
||||
return (
|
||||
<div
|
||||
data-component="session-background-hint"
|
||||
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
|
||||
>
|
||||
<span data-slot="session-background-hint-prefix" class="shrink-0">
|
||||
@@ -66,37 +66,6 @@ export function BackgroundMoveHint(props: { keybind?: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BackgroundMoveHintRow(props: { show: boolean; centered: boolean; padding: string }) {
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const visibility = createMemo<{ show: boolean; animate: boolean }>(
|
||||
(previous) => ({ show: props.show, animate: previous.animate || previous.show !== props.show }),
|
||||
{ show: props.show, animate: false },
|
||||
)
|
||||
const presence = createPresence({ show: () => visibility().show, element: () => ref() ?? null })
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
[`flex h-10 items-start pt-4 ${props.padding}`]: true,
|
||||
"animate-in fade-in": visibility().animate && visibility().show,
|
||||
"animate-out fade-out fill-mode-forwards": visibility().animate && !visibility().show,
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
@@ -323,13 +292,12 @@ type MessageTimelineProps = {
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: boolean
|
||||
onUserScroll: () => void
|
||||
onPin: () => void
|
||||
onUnpin: () => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onHistoryScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
shouldAnchorBottom: boolean
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
pinned: boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
|
||||
@@ -339,7 +307,6 @@ type MessageTimelineProps = {
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
@@ -396,28 +363,25 @@ function MessageTimelineView(
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
||||
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
|
||||
const pinned = createMemo(() => props.pinned)
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
sessionKey: props.data.sessionKey,
|
||||
projection,
|
||||
showHeader,
|
||||
shouldAnchorBottom,
|
||||
hasScrollGesture,
|
||||
pinned,
|
||||
scroll: () => props.scroll,
|
||||
onResumeScroll: props.onResumeScroll,
|
||||
setScrollRef: props.setScrollRef,
|
||||
setContentRef: props.setContentRef,
|
||||
onScheduleScrollState: props.onScheduleScrollState,
|
||||
onAutoScrollHandleScroll: props.onAutoScrollHandleScroll,
|
||||
onAutoScrollInteraction: props.onAutoScrollInteraction,
|
||||
onMarkScrollGesture: props.onMarkScrollGesture,
|
||||
onPin: props.onPin,
|
||||
onUnpin: props.onUnpin,
|
||||
onSelectionInteraction: props.onSelectionInteraction,
|
||||
onUserScroll: props.onUserScroll,
|
||||
onHistoryScroll: props.onHistoryScroll,
|
||||
setRevealMessage: props.setRevealMessage,
|
||||
setScrollToEnd: props.setScrollToEnd,
|
||||
setHistoryAnchor: props.setHistoryAnchor,
|
||||
})
|
||||
const VirtualizedTimeline = virtualized.View
|
||||
const [title, setTitle] = createStore({
|
||||
@@ -492,23 +456,51 @@ function MessageTimelineView(
|
||||
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
|
||||
return row.group.ref.partID
|
||||
})
|
||||
const backgroundHint = (row: TimelineRow.TimelineRow) =>
|
||||
row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === backgroundHintPartID()
|
||||
|
||||
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
|
||||
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
|
||||
(previous) => {
|
||||
const show = backgroundHintPartID() !== undefined
|
||||
return { show, animate: previous.animate || previous.show !== show }
|
||||
},
|
||||
{ show: backgroundHintPartID() !== undefined, animate: false },
|
||||
)
|
||||
const backgroundHintPresence = createPresence({
|
||||
show: () => backgroundHintVisibility().show,
|
||||
element: () => backgroundHintRef() ?? null,
|
||||
})
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
data-component="session-background-hint-row"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
[`flex h-8 items-start pt-2 ${turnPadding()}`]: true,
|
||||
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
deferred={(row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
|
||||
return content?.type === "tool" && ["edit", "write"].includes(content.name)
|
||||
}}
|
||||
renderRow={(row, onSizeChange) => (
|
||||
<>
|
||||
<rowRenderer.Row row={row} onSizeChange={onSizeChange} />
|
||||
<BackgroundMoveHintRow show={backgroundHint(row())} centered={props.centered} padding={turnPadding()} />
|
||||
</>
|
||||
)}
|
||||
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
|
||||
header={
|
||||
<div
|
||||
data-session-title
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
|
||||
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
|
||||
instance: Virtualizer<TScrollElement, TItemElement>,
|
||||
callback: (offset: number, isScrolling: boolean) => void,
|
||||
onReconnect?: () => void,
|
||||
) {
|
||||
let active = true
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
@@ -54,6 +55,7 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
|
||||
}
|
||||
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
|
||||
removed = false
|
||||
onReconnect?.()
|
||||
startCheck()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/session/message-gesture"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
createEffect,
|
||||
@@ -17,11 +16,14 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
|
||||
const fallbackItemSize = 60
|
||||
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
|
||||
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
|
||||
const endEpsilon = 0.5
|
||||
const upwardKeys = new Set(["up", "page-up", "home"])
|
||||
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
|
||||
|
||||
type Projection = Pick<
|
||||
@@ -33,25 +35,25 @@ type Input = {
|
||||
sessionKey: Accessor<string>
|
||||
projection: Projection
|
||||
showHeader: Accessor<boolean>
|
||||
shouldAnchorBottom: Accessor<boolean>
|
||||
hasScrollGesture: Accessor<boolean>
|
||||
/** True while the timeline follows the newest content. Drives every anchoring decision. */
|
||||
pinned: Accessor<boolean>
|
||||
scroll: Accessor<{ overflow: boolean; jump: boolean }>
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (element: HTMLDivElement | undefined) => void
|
||||
setContentRef: (element: HTMLDivElement) => void
|
||||
onScheduleScrollState: (element: HTMLDivElement) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
onUserScroll: () => void
|
||||
onPin: () => void
|
||||
onUnpin: () => void
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onHistoryScroll: () => void
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}
|
||||
|
||||
type ViewProps = {
|
||||
header: JSX.Element
|
||||
bottomSpacer?: JSX.Element
|
||||
workspaceSession: Accessor<boolean>
|
||||
deferred: (row: TimelineRow.TimelineRow) => boolean
|
||||
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
|
||||
@@ -62,90 +64,31 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
const cached = cache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom()
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
let touchGesture: number | undefined
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
let prependLoading = false
|
||||
let resizePinnedIndexes: number[] = []
|
||||
let resizePinFrame: number | undefined
|
||||
let gestureAnchorFrame: number | undefined
|
||||
const knownKeys = new Set(rows().map(TimelineRow.key))
|
||||
const addedKeys = new Set<string>()
|
||||
let touchStart: number | undefined
|
||||
let pointerHeld = false
|
||||
let maxScroll = 0
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
let scrollTop = 0
|
||||
|
||||
const clearPrependAnchor = () => {
|
||||
prependLoading = false
|
||||
prependAnchor = undefined
|
||||
if (prependAnchorFrame === undefined) return
|
||||
cancelAnimationFrame(prependAnchorFrame)
|
||||
prependAnchorFrame = undefined
|
||||
}
|
||||
|
||||
const capturePrependAnchor = () => {
|
||||
prependLoading = true
|
||||
updatePrependAnchor()
|
||||
}
|
||||
|
||||
const updatePrependAnchor = () => {
|
||||
const root = listRoot()
|
||||
if (!root) return
|
||||
const view = root.getBoundingClientRect()
|
||||
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
||||
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
||||
if (!anchor) return
|
||||
if (!anchor.element.dataset.timelineKey) return
|
||||
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
|
||||
}
|
||||
|
||||
const restorePrependAnchor = (done: boolean) => {
|
||||
if (done) prependLoading = false
|
||||
applyPrependAnchor()
|
||||
}
|
||||
|
||||
const applyPrependAnchor = () => {
|
||||
const root = listRoot()
|
||||
if (!root || !prependAnchor) return
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
let frames = 0
|
||||
let stable = 0
|
||||
const apply = () => {
|
||||
prependAnchorFrame = undefined
|
||||
const anchor = prependAnchor
|
||||
if (!anchor) return
|
||||
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
|
||||
const delta = element
|
||||
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
|
||||
: undefined
|
||||
if (delta !== undefined && Math.abs(delta) > 0.5) {
|
||||
root.scrollTop += delta
|
||||
stable = 0
|
||||
} else {
|
||||
stable += 1
|
||||
}
|
||||
frames += 1
|
||||
if (stable >= 30 || frames >= 180) {
|
||||
if (!prependLoading) prependAnchor = undefined
|
||||
return
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementOffset: observeElementOffsetReconnectAware,
|
||||
initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
observeElementOffset: (instance, callback) =>
|
||||
observeElementOffsetReconnectAware(instance, callback, () => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
}),
|
||||
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => fallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
@@ -154,83 +97,62 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
get getItemKey() {
|
||||
const items = rows()
|
||||
items
|
||||
.map(TimelineRow.key)
|
||||
.filter((key) => !knownKeys.has(key))
|
||||
.forEach((key) => {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
return (index: number) => {
|
||||
const row = items[index]
|
||||
if (!row) return `removed:${index}`
|
||||
return TimelineRow.key(row)
|
||||
}
|
||||
},
|
||||
anchorTo: "end",
|
||||
followOnAppend: true,
|
||||
get anchorTo() {
|
||||
return input.pinned() ? "end" : "start"
|
||||
},
|
||||
get followOnAppend() {
|
||||
return input.pinned()
|
||||
},
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return input.showHeader() ? 64 : 0
|
||||
},
|
||||
overscan: 50,
|
||||
paddingEnd: 64,
|
||||
rangeExtractor: (range) => {
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
const anchorAfterGesture = () => {
|
||||
if (gestureAnchorFrame !== undefined) return
|
||||
const apply = () => {
|
||||
gestureAnchorFrame = undefined
|
||||
if (input.hasScrollGesture()) {
|
||||
gestureAnchorFrame = requestAnimationFrame(apply)
|
||||
return
|
||||
}
|
||||
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
}
|
||||
gestureAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
|
||||
// reconciles the end. Coalesce one correction per measurement batch, before paint.
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (input.hasScrollGesture()) {
|
||||
anchorAfterGesture()
|
||||
return
|
||||
}
|
||||
if (!input.shouldAnchorBottom()) return
|
||||
if (!input.pinned()) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
const item = virtualizer.measurementsCache[index]
|
||||
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
|
||||
const root = listRoot()
|
||||
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
|
||||
const view = root.getBoundingClientRect()
|
||||
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.map((element) => Number(element.dataset.index))
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
resizePinFrame = requestAnimationFrame(() => {
|
||||
resizePinFrame = requestAnimationFrame(() => {
|
||||
resizePinFrame = undefined
|
||||
resizePinnedIndexes = []
|
||||
})
|
||||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
if (root && input.shouldAnchorBottom()) anchorResizedBottom()
|
||||
if (listRoot() && input.pinned()) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (input.shouldAnchorBottom()) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) {
|
||||
return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments
|
||||
}
|
||||
const first = instance.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
const virtualItemByKey = createMemo(
|
||||
@@ -244,97 +166,72 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (index === undefined) return
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
input.setScrollToEnd?.(() => virtualizer.scrollToEnd())
|
||||
input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
|
||||
input.setScrollToEnd?.(() => {
|
||||
input.onPin()
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
})
|
||||
|
||||
let overscanFrame: number | undefined
|
||||
onMount(() => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
})
|
||||
overscanFrame = undefined
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
})
|
||||
})
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
if (rows().length === 0) return
|
||||
if (input.hasScrollGesture()) {
|
||||
anchorAfterGesture()
|
||||
return
|
||||
}
|
||||
if (!input.shouldAnchorBottom()) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = input.sessionKey()
|
||||
createEffect(() => {
|
||||
const key = input.sessionKey()
|
||||
rows().length
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
virtualizer.measure()
|
||||
}
|
||||
maybeAnchorBottom()
|
||||
})
|
||||
|
||||
const bindListRoot = (root: HTMLDivElement) => {
|
||||
if (root === listRoot()) return
|
||||
setListRoot(root)
|
||||
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
|
||||
root.style.overflowAnchor = "none"
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
input.setScrollRef(root)
|
||||
}
|
||||
|
||||
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
|
||||
// the end must stop following, even though the resulting position still looks like the end.
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture })
|
||||
input.onUserScroll(event.target)
|
||||
if (event.deltaY < 0) input.onUnpin()
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
input.onUserScroll(event.target)
|
||||
touchStart = event.touches[0]?.clientY
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const next = event.touches[0]?.clientY
|
||||
const previous = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || previous === undefined) return
|
||||
const delta = previous - next
|
||||
if (!delta) return
|
||||
markBoundaryGesture({
|
||||
root: event.currentTarget,
|
||||
target: event.target,
|
||||
delta,
|
||||
onMarkScrollGesture: input.onMarkScrollGesture,
|
||||
})
|
||||
}
|
||||
|
||||
const handleListTouchEnd = () => {
|
||||
touchGesture = undefined
|
||||
const current = event.touches[0]?.clientY
|
||||
if (current === undefined || touchStart === undefined) return
|
||||
// Dragging the content downward reveals earlier messages.
|
||||
if (current <= touchStart) return
|
||||
touchStart = current
|
||||
input.onUnpin()
|
||||
}
|
||||
|
||||
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
|
||||
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
input.onMarkScrollGesture(event.target)
|
||||
}
|
||||
|
||||
const handleListPointerMove = (event: PointerEvent) => {
|
||||
if (event.buttons !== 1) return
|
||||
input.onMarkScrollGesture(event.target)
|
||||
input.onUserScroll(event.target)
|
||||
pointerHeld = true
|
||||
const release = () => {
|
||||
pointerHeld = false
|
||||
window.removeEventListener("pointerup", release)
|
||||
window.removeEventListener("pointercancel", release)
|
||||
}
|
||||
window.addEventListener("pointerup", release)
|
||||
window.addEventListener("pointercancel", release)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -342,22 +239,25 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!key) return
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
input.onMarkScrollGesture(event.currentTarget)
|
||||
input.onUserScroll(event.currentTarget)
|
||||
if (upwardKeys.has(key)) input.onUnpin()
|
||||
}
|
||||
|
||||
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
|
||||
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
|
||||
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
const root = event.currentTarget
|
||||
const movedUp = root.scrollTop < scrollTop - 0.5
|
||||
const previousTop = scrollTop
|
||||
const previousMaxScroll = maxScroll
|
||||
scrollTop = root.scrollTop
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
const atEnd = maxScroll - scrollTop <= endEpsilon
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
input.onScheduleScrollState(root)
|
||||
input.onHistoryScroll()
|
||||
if (!input.hasScrollGesture()) return
|
||||
if (!movedUp && root.scrollHeight - root.clientHeight - root.scrollTop >= 10) return
|
||||
input.onUserScroll()
|
||||
input.onAutoScrollHandleScroll()
|
||||
input.onMarkScrollGesture(root)
|
||||
}
|
||||
|
||||
function View(props: ViewProps) {
|
||||
@@ -408,7 +308,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
{props.renderRow(row, () => {
|
||||
setReady(true)
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
|
||||
contentMeasureFrame = requestAnimationFrame(() => {
|
||||
contentMeasureFrame = undefined
|
||||
if (element.isConnected) virtualizer.measureElement(element)
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,13 +353,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
onTouchMove={handleListTouchMove}
|
||||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onPointerMove={handleListPointerMove}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={input.onAutoScrollInteraction}
|
||||
onClick={input.onSelectionInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
|
||||
>
|
||||
@@ -473,10 +373,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<Show when={rows().length > 0}>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
aria-hidden="true"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
/>
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
@@ -485,17 +386,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clearPrependAnchor()
|
||||
cache.delete(ownerSessionKey)
|
||||
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
if (gestureAnchorFrame !== undefined) cancelAnimationFrame(gestureAnchorFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -506,30 +403,3 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
View,
|
||||
}
|
||||
}
|
||||
|
||||
function boundaryTarget(root: HTMLElement, target: EventTarget | null) {
|
||||
const current = target instanceof Element ? target : undefined
|
||||
const nested = current?.closest("[data-scrollable]")
|
||||
if (!(nested instanceof HTMLElement) || nested === root) return undefined
|
||||
return nested
|
||||
}
|
||||
|
||||
function markBoundaryGesture(input: {
|
||||
root: HTMLElement
|
||||
target: EventTarget | null
|
||||
delta: number
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
}) {
|
||||
const target = boundaryTarget(input.root, input.target)
|
||||
if (
|
||||
target &&
|
||||
!shouldMarkBoundaryGesture({
|
||||
delta: input.delta,
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
})
|
||||
)
|
||||
return
|
||||
input.onMarkScrollGesture(input.root)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useSessionHashScroll = (input: {
|
||||
pendingMessage: () => string | undefined
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: SessionMessageUser | undefined) => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
follow: { unpin: () => void; toBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
revealMessage?: (id: string) => void
|
||||
@@ -101,7 +101,7 @@ export const useSessionHashScroll = (input: {
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = location.hash.slice(1)
|
||||
if (!hash) {
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
input.follow.toBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
return
|
||||
@@ -109,7 +109,7 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const messageId = messageIdFromHash(hash)
|
||||
if (messageId) {
|
||||
input.autoScroll.pause()
|
||||
input.follow.unpin()
|
||||
const msg = messageById().get(messageId)
|
||||
if (msg) {
|
||||
scrollToMessage(msg, behavior)
|
||||
@@ -120,12 +120,12 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const target = document.getElementById(hash)
|
||||
if (target) {
|
||||
input.autoScroll.pause()
|
||||
input.follow.unpin()
|
||||
scrollToElement(target, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
input.follow.toBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export const useSessionHashScroll = (input: {
|
||||
if (pending) input.setPendingMessage(undefined)
|
||||
if (input.currentMessageId() === targetId && !pending) return
|
||||
|
||||
input.autoScroll.pause()
|
||||
input.follow.unpin()
|
||||
cancel()
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
|
||||
import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -123,6 +123,33 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props)
|
||||
)
|
||||
}
|
||||
|
||||
const TerminalPlacementSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: TerminalPlacement; label: string }[] => [
|
||||
{ value: "side", label: language.t("settings.general.row.terminalPlacement.side") },
|
||||
{ value: "bottom", label: language.t("settings.general.row.terminalPlacement.bottom") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.terminalPlacement.title")}
|
||||
description={language.t("settings.general.row.terminalPlacement.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-terminal-placement"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.general.terminalPlacement())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.general.setTerminalPlacement(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -273,6 +300,7 @@ export const SettingsGeneral: Component<{
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<TerminalPlacementSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||
export type WorkspaceLastUsed = "local" | "workspace"
|
||||
export type TerminalPlacement = "side" | "bottom"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -36,6 +37,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
terminalPlacement: TerminalPlacement
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -121,6 +123,7 @@ const defaultSettings: Settings = {
|
||||
editToolPartsExpanded: false,
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -240,6 +243,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
setStore("general", "mobileTitlebarPosition", value)
|
||||
},
|
||||
terminalPlacement: withFallback(
|
||||
() => store.general?.terminalPlacement,
|
||||
defaultSettings.general.terminalPlacement,
|
||||
),
|
||||
setTerminalPlacement(value: TerminalPlacement) {
|
||||
setStore("general", "terminalPlacement", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -27,6 +27,34 @@ test("end anchoring survives consecutive resizes when the first scroll write is
|
||||
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
|
||||
})
|
||||
|
||||
test("start anchoring preserves a stable visible item across prepends", () => {
|
||||
const root = document.createElement("div")
|
||||
const writes: number[] = []
|
||||
const options = (keys: string[]) => ({
|
||||
count: keys.length,
|
||||
estimateSize: () => 50,
|
||||
initialOffset: 50,
|
||||
initialRect: { width: 400, height: 100 },
|
||||
anchorTo: "start" as const,
|
||||
getItemKey: (index: number) => keys[index]!,
|
||||
getScrollElement: () => root,
|
||||
scrollToFn: (offset: number) => writes.push(offset),
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: (_element: HTMLDivElement, callback: (offset: number, isScrolling: boolean) => void) => {
|
||||
callback(50, false)
|
||||
},
|
||||
})
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(["c", "d", "e"]))
|
||||
virtualizer._willUpdate()
|
||||
virtualizer.getVirtualItems()
|
||||
|
||||
virtualizer.setOptions(options(["a", "b", "c", "d", "e"]))
|
||||
virtualizer._willUpdate()
|
||||
|
||||
expect(virtualizer.getScrollOffset()).toBe(150)
|
||||
expect(writes.at(-1)).toBe(150)
|
||||
})
|
||||
|
||||
test("reactive count updates preserve measured row sizes", () => {
|
||||
createRoot((dispose) => {
|
||||
const [count, setCount] = createSignal(2)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||
import { defineConfig } from "vite"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
import desktopPlugin from "./vite.js"
|
||||
|
||||
const sentry =
|
||||
@@ -20,7 +21,47 @@ const sentry =
|
||||
: false
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [desktopPlugin, sentry] as any,
|
||||
plugins: [
|
||||
desktopPlugin,
|
||||
VitePWA({
|
||||
strategies: "generateSW",
|
||||
manifest: false,
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
inlineWorkboxRuntime: true,
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api(?:\/|$)/],
|
||||
globPatterns: [
|
||||
"index.html",
|
||||
"site.webmanifest",
|
||||
"favicon*",
|
||||
"apple-touch-icon*",
|
||||
"web-app-manifest*",
|
||||
"assets/index-*.{js,css}",
|
||||
"assets/session-*.js",
|
||||
"assets/Inter.ttf",
|
||||
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith("/assets/"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "opencode-assets",
|
||||
cacheableResponse: {
|
||||
statuses: [200],
|
||||
},
|
||||
expiration: {
|
||||
maxEntries: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
sentry,
|
||||
] as any,
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
|
||||
@@ -32,9 +32,10 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets:
|
||||
if (request.method !== "GET" && request.method !== "HEAD")
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
|
||||
const html = name === "index.html"
|
||||
const revalidate = html || name === "sw.js" || name === "registerSW.js"
|
||||
const headers = {
|
||||
"content-type": FSUtil.mimeType(name),
|
||||
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
"cache-control": revalidate ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
"content-security-policy": html
|
||||
? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString())
|
||||
: csp(),
|
||||
|
||||
@@ -20,6 +20,8 @@ describe("web UI", () => {
|
||||
const assets = {
|
||||
"index.html": await Bun.file(index).text(),
|
||||
"app.js": await Bun.file(asset).text(),
|
||||
"sw.js": "service worker",
|
||||
"registerSW.js": "registration",
|
||||
"font.woff2": new Uint8Array([0, 1, 2, 255]),
|
||||
}
|
||||
|
||||
@@ -53,6 +55,13 @@ describe("web UI", () => {
|
||||
|
||||
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
|
||||
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
|
||||
expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable")
|
||||
|
||||
const worker = yield* Effect.promise(() => fetch(`${origin}/sw.js`))
|
||||
expect(worker.headers.get("cache-control")).toBe("no-cache")
|
||||
|
||||
const registration = yield* Effect.promise(() => fetch(`${origin}/registerSW.js`))
|
||||
expect(registration.headers.get("cache-control")).toBe("no-cache")
|
||||
|
||||
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
|
||||
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
|
||||
|
||||
@@ -1101,6 +1101,9 @@ export function createData(config: CreateDataInput) {
|
||||
get(sessionID: string) {
|
||||
return store.session.info[sessionID]
|
||||
},
|
||||
creating(sessionID: string) {
|
||||
return creating.has(sessionID)
|
||||
},
|
||||
remember(info: SessionInfo) {
|
||||
setStore("session", "info", info.id, reconcile(info))
|
||||
sync.complete(`session:${info.id}`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
@@ -72,6 +72,37 @@ test("revalidates after an event overtakes an active session read", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
if (!request.url.endsWith("/api/session")) throw new Error(`Unexpected request: ${request.url}`)
|
||||
await release.promise
|
||||
return Response.json({ data: session(0) })
|
||||
},
|
||||
})
|
||||
const event: CreateDataInput["event"] = {
|
||||
on: () => () => {},
|
||||
listen: () => () => {},
|
||||
}
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
const created = setup.data.session.create({ id: "ses_refresh", location: { directory: "/project" } })
|
||||
expect(setup.data.session.creating(created.id)).toBe(true)
|
||||
release.resolve()
|
||||
await created.request
|
||||
expect(setup.data.session.creating(created.id)).toBe(false)
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export * as LocationActivity from "./location-activity.js"
|
||||
|
||||
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const isSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
export class Service extends Context.Service<Service, {}>()("@opencode/LocationActivity") {}
|
||||
|
||||
export function layer(options: { readonly timeToLive?: Duration.Input; readonly sweepInterval?: Duration.Input } = {}) {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
const touch = (ref: Location.Ref) =>
|
||||
Effect.sync(() => {
|
||||
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
|
||||
})
|
||||
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (!isSessionEvent(event)) return Effect.void
|
||||
const location = event.location
|
||||
if (!location) return Effect.void
|
||||
return RcMap.has(locations.rcMap, location).pipe(
|
||||
Effect.flatMap((active) => (active ? touch(location) : Effect.void)),
|
||||
)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const cached = new Set(refs.map(key))
|
||||
yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const id of entries.keys()) {
|
||||
if (!cached.has(id)) entries.delete(id)
|
||||
}
|
||||
const now = clock.currentTimeMillisUnsafe()
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) => {
|
||||
entries.delete(key(entry.ref))
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
}).pipe(Effect.forever, Effect.forkScoped)
|
||||
|
||||
return Service.of({})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, LocationServiceMap.node],
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
@@ -147,7 +147,7 @@ export function buildLocationServiceMap(
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
|
||||
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
|
||||
),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
|
||||
@@ -130,7 +130,7 @@ secret into configuration.
|
||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
|
||||
the repository, its source is `packages/www/src/docs/content/migrate-v1.mdx`.
|
||||
|
||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||
The only intentional breaking changes are the server API and plugin API. Native
|
||||
|
||||
@@ -3,14 +3,16 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
@@ -21,6 +23,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
@@ -41,8 +44,55 @@ const itWithSdk = testEffect(
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref) =>
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
)
|
||||
const itWithActivity = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
|
||||
[[LocationServiceMap.node, activityLocations]],
|
||||
),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
itWithActivity.effect("refreshes lifetime from Session events only", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const sessionID = Session.ID.make("ses_location_activity")
|
||||
const read = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
|
||||
|
||||
yield* read
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
yield* bus.publish(Catalog.Event.Updated, {}, { location: ref })
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
|
||||
yield* read
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID }, { location: ref })
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, { location: ref })
|
||||
yield* TestClock.adjust("1 minute")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([ref])
|
||||
yield* TestClock.adjust("59 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retries a location after its missing directory is recreated", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -878,7 +878,6 @@ describe("ShellTool", () => {
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
if (!isWindows) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
@@ -62,6 +63,7 @@ const applicationServiceNodes = [
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
LocationServiceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
] as const
|
||||
const applicationServices = LayerNode.group(applicationServiceNodes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { patchFile, patchFiles } from "./apply-patch-file"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { patchFile, patchFileGroups, patchFiles } from "./apply-patch-file"
|
||||
|
||||
describe("apply patch files", () => {
|
||||
test("parses current file diffs", () => {
|
||||
@@ -28,4 +29,46 @@ describe("apply patch files", () => {
|
||||
{ path: "src/old.ts", type: "delete" },
|
||||
])
|
||||
})
|
||||
|
||||
test("composes sequential complete patches for the same file", () => {
|
||||
const before = "const a = 1\nconst b = 2\n"
|
||||
const middle = "const a = 2\nconst b = 2\n"
|
||||
const after = "const a = 2\nconst b = 3\n"
|
||||
const patch = (oldText: string, newText: string) =>
|
||||
createTwoFilesPatch("a/src/a.ts", "b/src/a.ts", oldText, newText).replace(
|
||||
/^(?:Index: [^\n]+\n)?=+\n/,
|
||||
"diff --git a/src/a.ts b/src/a.ts\n",
|
||||
)
|
||||
const groups = patchFileGroups([
|
||||
{
|
||||
file: "src/a.ts",
|
||||
patch: patch(before, middle),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
},
|
||||
{
|
||||
file: "src/a.ts",
|
||||
patch: patch(middle, after),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
},
|
||||
])
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.views).toHaveLength(1)
|
||||
expect(groups[0]?.additions).toBe(2)
|
||||
expect(groups[0]?.deletions).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps sequential partial patches under one file", () => {
|
||||
const groups = patchFileGroups([
|
||||
{ file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b", additions: 1, deletions: 1, status: "modified" },
|
||||
{ file: "src/a.ts", patch: "@@ -2 +2 @@\n-c\n+d", additions: 1, deletions: 1, status: "modified" },
|
||||
])
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.views).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { normalize, type ViewDiff } from "./session-diff"
|
||||
import { diffLines } from "diff"
|
||||
import { completePatchContents, normalize, type ViewDiff } from "./session-diff"
|
||||
|
||||
type Kind = "add" | "update" | "delete"
|
||||
|
||||
@@ -9,8 +10,11 @@ export type ApplyPatchFile = {
|
||||
additions: number
|
||||
deletions: number
|
||||
view: ViewDiff
|
||||
contents?: { before: string; after: string }
|
||||
}
|
||||
|
||||
export type ApplyPatchFileGroup = Omit<ApplyPatchFile, "view" | "contents"> & { views: ViewDiff[] }
|
||||
|
||||
function fileDiff(value: unknown): value is FileDiffInfo {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (!("file" in value) || typeof value.file !== "string") return false
|
||||
@@ -29,6 +33,7 @@ export function patchFile(value: unknown): ApplyPatchFile | undefined {
|
||||
additions: value.additions,
|
||||
deletions: value.deletions,
|
||||
view: normalize(value),
|
||||
contents: completePatchContents(value.patch),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,3 +41,53 @@ export function patchFiles(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
|
||||
}
|
||||
|
||||
export function patchFileGroups(value: unknown): ApplyPatchFileGroup[] {
|
||||
const groups = patchFiles(value).reduce((result, file) => {
|
||||
const files = result.get(file.path)
|
||||
if (files) files.push(file)
|
||||
if (!files) result.set(file.path, [file])
|
||||
return result
|
||||
}, new Map<string, ApplyPatchFile[]>())
|
||||
return [...groups].map(([path, files]) => {
|
||||
const first = files[0]!
|
||||
const last = files.at(-1)!
|
||||
const type = last.type === "delete" ? "delete" : first.type === "add" ? "add" : "update"
|
||||
const chained = files.every(
|
||||
(file, index) => !!file.contents && (index === 0 || files[index - 1]?.contents?.after === file.contents.before),
|
||||
)
|
||||
if (!chained) {
|
||||
return {
|
||||
path,
|
||||
type,
|
||||
additions: files.reduce((total, file) => total + file.additions, 0),
|
||||
deletions: files.reduce((total, file) => total + file.deletions, 0),
|
||||
views: files.map((file) => file.view),
|
||||
}
|
||||
}
|
||||
|
||||
const before = first.contents!.before
|
||||
const after = last.contents!.after
|
||||
const counts = diffLines(before, after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
return {
|
||||
path,
|
||||
type,
|
||||
...counts,
|
||||
views: [
|
||||
normalize({
|
||||
file: path,
|
||||
before,
|
||||
after,
|
||||
status: type === "add" ? "added" : type === "delete" ? "deleted" : "modified",
|
||||
...counts,
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function fileDiffFromPatch(file: string, patch: string) {
|
||||
return value
|
||||
}
|
||||
|
||||
function completePatchContents(patch: string) {
|
||||
export function completePatchContents(patch: string) {
|
||||
try {
|
||||
const parsed = parsePatch(patch)[0]
|
||||
if (!parsed || (!parsed.index && !parsed.oldFileName && !parsed.newFileName)) return
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
import { Match, Switch } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
|
||||
import { CurrentContextToolGroup, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer"
|
||||
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
|
||||
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
|
||||
|
||||
export type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
@@ -110,9 +110,18 @@ export function SessionContextToolGroup(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionPatchToolGroup(props: {
|
||||
export function SessionFileToolGroup(props: {
|
||||
tools: SessionMessageAssistantTool[]
|
||||
fileOpen: (path: string) => boolean | undefined
|
||||
onFileOpenChange: (path: string, open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
return <CurrentPatchToolGroup tools={props.tools} onSizeChange={props.onSizeChange} />
|
||||
return (
|
||||
<CurrentFileToolGroup
|
||||
tools={props.tools}
|
||||
fileOpen={props.fileOpen}
|
||||
onFileOpenChange={props.onFileOpenChange}
|
||||
onSizeChange={props.onSizeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
|
||||
|
||||
const context = (
|
||||
key: string,
|
||||
partIDs: string[],
|
||||
identity: { userMessageID?: string; messageID?: string } = {},
|
||||
) =>
|
||||
const context = (key: string, partIDs: string[], identity: { userMessageID?: string; messageID?: string } = {}) =>
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: identity.userMessageID ?? "user-1",
|
||||
group: {
|
||||
@@ -22,7 +18,7 @@ const patch = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
userMessageID,
|
||||
group: {
|
||||
key,
|
||||
type: "patch",
|
||||
type: "file",
|
||||
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
|
||||
} satisfies PartGroup,
|
||||
previousAssistantPart: false,
|
||||
|
||||
@@ -446,20 +446,15 @@ function renderable(content: Content, showReasoning: boolean) {
|
||||
|
||||
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
if (!first) return
|
||||
if (current.type === "patch" && current.refs.length === 1) {
|
||||
groups.push({ type: "part", key: `part:${first.messageID}:${first.partID}`, ref: first })
|
||||
adjacent = undefined
|
||||
return
|
||||
}
|
||||
groups.push({
|
||||
type: current.type,
|
||||
type: current.type === "context" ? "context" : "file",
|
||||
key:
|
||||
current.type === "patch"
|
||||
current.type !== "context"
|
||||
? `part:${first.messageID}:${first.partID}`
|
||||
: `context:${first.messageID}:${first.partID}`,
|
||||
refs: current.refs,
|
||||
@@ -473,7 +468,9 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
|
||||
? "context"
|
||||
: item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error"
|
||||
? "patch"
|
||||
: undefined
|
||||
: item.content.type === "tool" && item.content.name === "edit" && item.content.state.status !== "error"
|
||||
? "edit"
|
||||
: undefined
|
||||
if (type) {
|
||||
if (adjacent?.type !== type) flush()
|
||||
adjacent ??= { type, refs: [] }
|
||||
|
||||
@@ -433,7 +433,12 @@ describe("current session timeline rows", () => {
|
||||
type: "tool",
|
||||
id: "tool_patch_1",
|
||||
name: "patch",
|
||||
state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
metadata: { files: [] },
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
@@ -459,9 +464,28 @@ describe("current session timeline rows", () => {
|
||||
type: "tool",
|
||||
id: "tool_patch_3",
|
||||
name: "patch",
|
||||
state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
metadata: { files: [] },
|
||||
},
|
||||
time: { created: 7, completed: 8 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_edit_1",
|
||||
name: "edit",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 9 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_edit_2",
|
||||
name: "edit",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 10 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
@@ -472,7 +496,7 @@ describe("current session timeline rows", () => {
|
||||
|
||||
expect(groups).toEqual([
|
||||
{
|
||||
type: "patch",
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_patch_1" },
|
||||
@@ -485,9 +509,17 @@ describe("current session timeline rows", () => {
|
||||
ref: { messageID: "msg_assistant", partID: "tool_patch_failed" },
|
||||
},
|
||||
{
|
||||
type: "part",
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_3",
|
||||
ref: { messageID: "msg_assistant", partID: "tool_patch_3" },
|
||||
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_edit_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_1" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_2" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
MessageDivider,
|
||||
SessionAssistantContent,
|
||||
SessionContextToolGroup,
|
||||
SessionPatchToolGroup,
|
||||
SessionFileToolGroup,
|
||||
SessionShellMessage,
|
||||
SessionUserMessage,
|
||||
currentContentDefaultOpen,
|
||||
@@ -99,19 +99,36 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
)
|
||||
}
|
||||
|
||||
if (row().group.type === "patch") {
|
||||
if (row().group.type === "file") {
|
||||
const tools = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "patch") return []
|
||||
if (group.type !== "file") return []
|
||||
return group.refs.flatMap((ref) => {
|
||||
const message = input.projection.messageByID().get(ref.messageID)
|
||||
const content = Timeline.resolveContent(message, ref.partID)
|
||||
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
|
||||
})
|
||||
})
|
||||
const firstPath = createMemo(() => {
|
||||
const tool = tools()[0]
|
||||
if (!tool || !("metadata" in tool.state)) return undefined
|
||||
const files = tool.state.metadata?.files
|
||||
if (!Array.isArray(files)) return undefined
|
||||
const file = files[0]
|
||||
return file && typeof file === "object" && "file" in file && typeof file.file === "string"
|
||||
? file.file
|
||||
: undefined
|
||||
})
|
||||
return (
|
||||
<SessionPatchToolGroup
|
||||
<SessionFileToolGroup
|
||||
tools={tools()}
|
||||
fileOpen={(path) => {
|
||||
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
|
||||
if (open !== undefined) return open
|
||||
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
|
||||
return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen()
|
||||
}}
|
||||
onFileOpenChange={(path, open) => input.disclosure.set(`${row().group.key}:file:${path}`, open)}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
)
|
||||
@@ -173,7 +190,11 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (message.type === "system") {
|
||||
const prefix = "Instructions updated: "
|
||||
if (message.description?.startsWith(prefix)) {
|
||||
const keys = message.description.slice(prefix.length).split(",").map((s) => s.trim()).filter(Boolean)
|
||||
const keys = message.description
|
||||
.slice(prefix.length)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
return {
|
||||
label: i18n.t("ui.sessionTimeline.notice.instructionsUpdated"),
|
||||
items: keys,
|
||||
|
||||
@@ -18,7 +18,7 @@ export type PartGroup =
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
type: "patch"
|
||||
type: "file"
|
||||
refs: PartRef[]
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { AnimatedCountList } from "../components/tool-count-summary"
|
||||
import { ToolStatusTitle } from "../components/tool-status-title"
|
||||
import { patchFiles } from "../components/apply-patch-file"
|
||||
import { patchFileGroups } from "../components/apply-patch-file"
|
||||
import { animate } from "motion"
|
||||
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
|
||||
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
|
||||
@@ -562,32 +562,55 @@ export function CurrentContextToolGroup(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function CurrentPatchToolGroup(props: {
|
||||
export function CurrentFileToolGroup(props: {
|
||||
tools: SessionMessageAssistantTool[]
|
||||
fileOpen: (path: string) => boolean | undefined
|
||||
onFileOpenChange: (path: string, open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
const metadata = createMemo(() => ({
|
||||
files: props.tools.flatMap((tool) => {
|
||||
const files = createMemo((previous: { key: string; value: unknown }[]) => {
|
||||
const next = props.tools.flatMap((tool) => {
|
||||
const files = currentToolMetadata(tool).files
|
||||
return Array.isArray(files) ? files : []
|
||||
}),
|
||||
if (!Array.isArray(files)) return []
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, value }))
|
||||
})
|
||||
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
|
||||
const existing = new Set(previous.map((entry) => entry.key))
|
||||
const result = [
|
||||
...previous.map((entry) => {
|
||||
if (!updates.has(entry.key)) return entry
|
||||
const value = updates.get(entry.key)
|
||||
return samePatchFile(value, entry.value) ? entry : { key: entry.key, value }
|
||||
}),
|
||||
...next.filter((entry) => !existing.has(entry.key)),
|
||||
]
|
||||
return result.length === previous.length && result.every((entry, index) => entry === previous[index])
|
||||
? previous
|
||||
: result
|
||||
}, [])
|
||||
const metadata = createMemo(() => ({
|
||||
files: files().map((entry) => entry.value),
|
||||
}))
|
||||
const pending = createMemo(() =>
|
||||
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="tool-part-wrapper"
|
||||
data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}
|
||||
data-timeline-part-id={props.tools.length === 1 ? props.tools[0]?.id : undefined}
|
||||
data-timeline-part-ids={props.tools.length > 1 ? props.tools.map((tool) => tool.id).join(",") : undefined}
|
||||
>
|
||||
<Dynamic
|
||||
component={render}
|
||||
tool="patch"
|
||||
tool={tool()}
|
||||
input={{}}
|
||||
metadata={metadata()}
|
||||
status={pending() ? "running" : "completed"}
|
||||
fileOpen={props.fileOpen}
|
||||
onFileOpenChange={props.onFileOpenChange}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
@@ -596,6 +619,18 @@ export function CurrentPatchToolGroup(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function samePatchFile(a: unknown, b: unknown) {
|
||||
if (a === b) return true
|
||||
if (!record(a) || !record(b)) return false
|
||||
return (
|
||||
a.file === b.file &&
|
||||
a.patch === b.patch &&
|
||||
a.additions === b.additions &&
|
||||
a.deletions === b.deletions &&
|
||||
a.status === b.status
|
||||
)
|
||||
}
|
||||
|
||||
function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: ReturnType<typeof useI18n>) {
|
||||
const input = currentToolInput(tool)
|
||||
const metadata = currentToolMetadata(tool)
|
||||
@@ -643,6 +678,8 @@ export interface ToolProps {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
fileOpen?: (path: string) => boolean | undefined
|
||||
onFileOpenChange?: (path: string, open: boolean) => void
|
||||
deferContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
onContentRendered?: () => void
|
||||
@@ -674,7 +711,12 @@ export const ToolRegistry = {
|
||||
render: getTool,
|
||||
}
|
||||
|
||||
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) {
|
||||
function ToolFileAccordion(props: {
|
||||
path: string
|
||||
actions?: JSX.Element
|
||||
children: JSX.Element
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const value = createMemo(() => props.path || "tool-file")
|
||||
|
||||
return (
|
||||
@@ -1230,6 +1272,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const streaming = () => props.status === "streaming"
|
||||
const pending = () => streaming() || props.status === "running" || props.metadata.status === "running"
|
||||
const sawStreaming = streaming()
|
||||
const command = () => {
|
||||
if (typeof props.input.command === "string") return props.input.command
|
||||
@@ -1253,7 +1296,7 @@ ToolRegistry.register({
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={i18n.t("ui.tool.shell")}
|
||||
active={streaming() || props.status === "running" || props.metadata.status === "running"}
|
||||
active={pending()}
|
||||
/>
|
||||
</span>
|
||||
<Show when={!open()}>
|
||||
@@ -1495,13 +1538,23 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const files = createMemo(() => patchFiles(props.metadata.files))
|
||||
const single = createMemo(() => {
|
||||
const list = files()
|
||||
if (list.length !== 1) return undefined
|
||||
return list[0]
|
||||
})
|
||||
const files = createMemo(() => patchFileGroups(props.metadata.files))
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
const title = createMemo(() =>
|
||||
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
|
||||
)
|
||||
const open = createMemo(() => {
|
||||
if (!props.fileOpen) return expanded()
|
||||
return files().flatMap((file) => (props.fileOpen?.(file.path) === true ? [file.path] : []))
|
||||
})
|
||||
const change = (value: string | string[]) => {
|
||||
const next = Array.isArray(value) ? value : value ? [value] : []
|
||||
if (!props.onFileOpenChange) {
|
||||
setExpanded(next)
|
||||
return
|
||||
}
|
||||
files().forEach((file) => props.onFileOpenChange?.(file.path, next.includes(file.path)))
|
||||
}
|
||||
|
||||
const subtitle = createMemo(() => {
|
||||
const count = files().length
|
||||
@@ -1510,159 +1563,110 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={single()}
|
||||
fallback={
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
open
|
||||
onOpenChange={undefined}
|
||||
locked
|
||||
icon="code-lines"
|
||||
defer={false}
|
||||
rail={false}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.patch"),
|
||||
subtitle: subtitle(),
|
||||
}}
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
open
|
||||
onOpenChange={undefined}
|
||||
locked
|
||||
icon="code-lines"
|
||||
defer={false}
|
||||
rail={false}
|
||||
trigger={{
|
||||
title: title(),
|
||||
subtitle: subtitle(),
|
||||
}}
|
||||
>
|
||||
<Show when={files().length > 0}>
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||
value={open()}
|
||||
onChange={change}
|
||||
>
|
||||
<Show when={files().length > 0}>
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file, index) => {
|
||||
const value = () => `${index()}:${file.path}`
|
||||
const active = createMemo(() => expanded().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
const active = createMemo(() => open().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Accordion.Item value={value()} data-type={file.type}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file.path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file.path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file.path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file.type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file.type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file.additions, deletions: file.deletions }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
return (
|
||||
<Accordion.Item value={value()} data-type={file().type}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={file.view.fileDiff}
|
||||
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
open
|
||||
onOpenChange={undefined}
|
||||
locked
|
||||
icon="code-lines"
|
||||
defer={false}
|
||||
trigger={{ title: i18n.t("ui.tool.patch"), subtitle: subtitle() }}
|
||||
rail={false}
|
||||
>
|
||||
<ToolFileAccordion
|
||||
path={single()!.path}
|
||||
defaultOpen={false}
|
||||
actions={
|
||||
<Switch>
|
||||
<Match when={single()!.type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={single()!.type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: single()!.additions, deletions: single()!.deletions }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={single()!.view.fileDiff}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</BasicTool>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -486,6 +486,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
if (route.data.type !== "session") return
|
||||
const session = data.session.get(route.data.sessionID)
|
||||
if (!session) return
|
||||
if (data.session.creating(session.id)) return
|
||||
if (session.location.workspaceID !== undefined || terminalEnvironment.variables === undefined) return
|
||||
void client.api.session
|
||||
.environment({ sessionID: session.id, variables: terminalEnvironment.variables })
|
||||
|
||||
@@ -956,7 +956,7 @@ export function Autocomplete(props: {
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
{" " + option().description?.replace(/\s+/g, " ").trim()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
|
||||
type TabsState = {
|
||||
tabs: SessionTab[]
|
||||
// Read only long enough to remove the former client-owned state from persisted tab files.
|
||||
unread?: Record<string, unknown>
|
||||
// Kept empty for rollback compatibility with clients that still read this field.
|
||||
unread: Record<string, unknown>
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
@@ -43,7 +43,7 @@ type ScrollAnchor = {
|
||||
screenY: number
|
||||
}
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [] })
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
const TAB_PREFETCH_DELAY = 300
|
||||
@@ -128,6 +128,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
}, []),
|
||||
unread: {},
|
||||
})
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const newTab = createMemo((open = false) => {
|
||||
@@ -219,7 +220,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
update((draft) => {
|
||||
const next = normalize(draft)
|
||||
draft.tabs = next.tabs
|
||||
delete draft.unread
|
||||
draft.unread = next.unread
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -265,10 +265,10 @@ test("stores session tabs for the current working directory by default", async (
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
const stored = await Bun.file(file).json()
|
||||
expect(stored.global).toEqual({ tabs: [] })
|
||||
expect(stored.global).toEqual({ tabs: [], unread: {} })
|
||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
||||
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
|
||||
expect(stored.cwd[directory]).not.toHaveProperty("unread")
|
||||
expect(stored.cwd[directory].unread).toEqual({})
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
@@ -336,14 +336,14 @@ test("acknowledges viewed sessions even when tabs are disabled", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("purges legacy persisted unread records", async () => {
|
||||
test("empties legacy persisted unread records for rollback compatibility", async () => {
|
||||
const setup = await renderSessionTabs("first", { persisted: ["first"] })
|
||||
try {
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
// Normalize rewrites the active scope; the legacy record must not survive it.
|
||||
// Normalize rewrites the active scope; legacy values must not survive, but older clients require the field.
|
||||
await wait(async () => {
|
||||
const stored = await Bun.file(file).json()
|
||||
return !("unread" in stored.cwd[directory])
|
||||
return Object.keys(stored.cwd[directory].unread).length === 0
|
||||
})
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
|
||||
@@ -81,6 +81,7 @@ function init() {
|
||||
let dispose: (() => void) | undefined
|
||||
let setClosing: ((closing: boolean) => void) | undefined
|
||||
|
||||
// Stacked dialogs render as sibling portals, so only the top layer may own the focus trap.
|
||||
const node = runWithOwner(owner, () =>
|
||||
createRoot((d: () => void) => {
|
||||
dispose = d
|
||||
@@ -88,7 +89,7 @@ function init() {
|
||||
setClosing = setClosingSignal
|
||||
return (
|
||||
<Kobalte
|
||||
modal
|
||||
modal={stack().length <= layer + 1}
|
||||
open={!closing()}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (open) return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.blume/
|
||||
.astro/
|
||||
.wrangler/
|
||||
dist/
|
||||
node_modules/
|
||||
.blume-verify/
|
||||
|
||||
+8
-12
@@ -1,22 +1,18 @@
|
||||
# Website and documentation guide
|
||||
# Website guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This package owns the `opencode.ai` website.
|
||||
- Add custom marketing routes as Astro files under `pages/`.
|
||||
- The whole project is mounted at `/v2/` by `deployment.base`, and documentation lives under `content/docs/` at `/v2/docs` through `basePath` in `blume.config.ts`.
|
||||
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
|
||||
- Use parenthesized content folders for sidebar groups that must not add a URL segment. Keep ungrouped top-level pages directly under `content/docs/`.
|
||||
- Put static files in `public/` and reference them with root-relative paths.
|
||||
- The API reference is generated from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
|
||||
- Keep documentation aligned with the current packages.
|
||||
- This is a standard Astro website with a fully owned frontend.
|
||||
- Only `base` in `astro.config.ts` should know the deployment subpath. Use `import.meta.env.BASE_URL` everywhere else.
|
||||
- The documentation feature is self-contained under `src/docs/` and renders through the thin route in `src/pages/docs/`.
|
||||
- Do not add a documentation frontend framework; this package owns the UI directly.
|
||||
- Keep the installer route dynamic and `public/openapi.json` generated from `packages/protocol/openapi.json`.
|
||||
|
||||
## Local development
|
||||
|
||||
- Run `bun dev` from this package and preview the site at `http://localhost:3000/v2/`.
|
||||
- Verify both custom marketing routes and documentation routes after changing shared navigation or layout code.
|
||||
- Run `bun dev` from this package and use the local URL printed by Astro.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun typecheck`, `bun validate`, and `bun run build` from this package after documentation or configuration changes.
|
||||
- Run `bun typecheck`, `bun run check:generated`, and `bun run build` from this package after changes.
|
||||
- Treat validation and build errors as blockers.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# OpenCode website
|
||||
|
||||
The OpenCode V2 website, powered by Blume and deployed with Wrangler at `https://opencode.ai/v2/`. Blume mounts the documentation at `/v2/docs`, and `https://v2.opencode.ai` redirects to the same deployment.
|
||||
The standard Astro website for OpenCode. The only deployment-specific path is the `base` setting in `astro.config.ts`; application code uses `import.meta.env.BASE_URL`.
|
||||
|
||||
Wrangler deploys the site through Blume's Cloudflare server adapter. Documentation pages are prerendered, while custom dynamic routes and endpoints can run in the Worker. Production uses `opencode-www` at `opencode.ai/v2/`; dev uses `opencode-www-dev` at `dev.opencode.ai/v2/`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
|
||||
- `src/pages/` owns website routes.
|
||||
- `src/docs/` is the self-contained documentation feature rendered under `/docs`.
|
||||
- `/install` proxies the current installer.
|
||||
- `/openapi.json` serves the generated OpenAPI specification.
|
||||
|
||||
The `deploy-www` GitHub workflow deploys the `dev` branch to the dev Worker and the `v2` branch to the production Worker.
|
||||
The deployment currently sets `base: "/v2"`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -14,12 +17,12 @@ From this directory, run:
|
||||
bun dev
|
||||
```
|
||||
|
||||
The site opens at `http://localhost:3000/v2/`; documentation is available at `http://localhost:3000/v2/docs`.
|
||||
The local URL includes the base configured in `astro.config.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bun typecheck
|
||||
bun validate
|
||||
bun run check:generated
|
||||
bun run build
|
||||
```
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import cloudflare from "@astrojs/cloudflare"
|
||||
import mdx from "@astrojs/mdx"
|
||||
import { unified } from "@astrojs/markdown-remark"
|
||||
import { defineConfig } from "astro/config"
|
||||
import remarkDocsLinks from "./src/docs/remark-links"
|
||||
|
||||
const base = "/v2/"
|
||||
|
||||
export default defineConfig({
|
||||
site: process.env.CLOUDFLARE_ENV === "production" ? "https://opencode.ai" : "https://dev.opencode.ai",
|
||||
base,
|
||||
output: "server",
|
||||
adapter: cloudflare({ imageService: "passthrough" }),
|
||||
integrations: [mdx()],
|
||||
markdown: {
|
||||
processor: unified({ remarkPlugins: [[remarkDocsLinks, { base }]] }),
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
transformers: [
|
||||
{
|
||||
name: "code-block-title",
|
||||
root(root) {
|
||||
const title = this.options.meta?.__raw?.match(/title="([^"]+)"/)?.[1]
|
||||
if (!title) return
|
||||
const pre = root.children[0]
|
||||
if (!pre || pre.type !== "element") return
|
||||
root.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "figure",
|
||||
properties: { className: ["astro-code-figure"] },
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "figcaption",
|
||||
properties: { className: ["astro-code-title"] },
|
||||
children: [{ type: "text", value: title }],
|
||||
},
|
||||
pre,
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { defineConfig } from "blume"
|
||||
|
||||
export default defineConfig({
|
||||
title: "OpenCode",
|
||||
description: "The open source AI coding agent.",
|
||||
basePath: "/docs",
|
||||
logo: {
|
||||
image: {
|
||||
light: "/assets/logo-light.svg",
|
||||
dark: "/assets/logo-dark.svg",
|
||||
alt: "OpenCode",
|
||||
},
|
||||
text: "",
|
||||
href: "/",
|
||||
},
|
||||
content: {
|
||||
root: "content/docs",
|
||||
},
|
||||
github: {
|
||||
owner: "anomalyco",
|
||||
repo: "opencode",
|
||||
branch: "v2",
|
||||
dir: "packages/www",
|
||||
},
|
||||
theme: {
|
||||
background: { dark: "#000000" },
|
||||
fonts: {
|
||||
body: { name: "Monaspace Neon", provider: "fontsource", weights: [400, 500, 600, 700], fallback: "mono" },
|
||||
display: { name: "Monaspace Neon", provider: "fontsource", weights: [400, 500, 600, 700], fallback: "mono" },
|
||||
mono: { name: "Monaspace Neon", provider: "fontsource", weights: [400, 500, 600, 700], fallback: "mono" },
|
||||
},
|
||||
mode: "dark",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{ label: "Docs", path: "/" },
|
||||
{ label: "CLI", path: "/cli" },
|
||||
{ label: "Build", path: "/build" },
|
||||
{ label: "API", path: "/api" },
|
||||
],
|
||||
},
|
||||
markdown: {
|
||||
code: {
|
||||
icons: false,
|
||||
},
|
||||
},
|
||||
openapi: {
|
||||
enabled: true,
|
||||
route: "/api",
|
||||
spec: "./openapi.json",
|
||||
},
|
||||
seo: {
|
||||
og: {
|
||||
fonts: [{ name: "IBM Plex Mono", weight: [400, 600] }],
|
||||
logo: "public/assets/logo-dark.svg",
|
||||
palette: {
|
||||
accent: "#b7b1b1",
|
||||
background: "#131010",
|
||||
border: "#343030",
|
||||
foreground: "#f1ecec",
|
||||
muted: "#b7b1b1",
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment: {
|
||||
adapter: "cloudflare",
|
||||
base: "/v2/",
|
||||
output: "server",
|
||||
site: process.env.BLUME_ENV === "dev" ? "https://dev.opencode.ai" : "https://opencode.ai",
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineComponents } from "blume"
|
||||
import Breadcrumbs from "./components/Breadcrumbs.astro"
|
||||
import ThemeTokens from "./snippets/generated/theme-tokens.mdx"
|
||||
|
||||
export default defineComponents({
|
||||
layout: {
|
||||
Breadcrumbs,
|
||||
},
|
||||
mdx: {
|
||||
ThemeTokens,
|
||||
},
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
---
|
||||
---
|
||||
@@ -1,24 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Configure",
|
||||
pages: [
|
||||
"lsp",
|
||||
"agents",
|
||||
"models",
|
||||
"skills",
|
||||
"themes",
|
||||
"commands",
|
||||
"providers",
|
||||
"snapshots",
|
||||
"compaction",
|
||||
"formatters",
|
||||
"references",
|
||||
"attachments",
|
||||
"mcp-servers",
|
||||
"permissions",
|
||||
"instructions",
|
||||
"sharing",
|
||||
"warming",
|
||||
],
|
||||
})
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Build",
|
||||
pages: ["sdk", "index", "client", "plugins"],
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Configure",
|
||||
pages: ["theme", "plugins", "keybinds"],
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
title: "Intro",
|
||||
pages: ["index", "config", "configure", "providers"],
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { defineMeta } from "blume"
|
||||
|
||||
export default defineMeta({
|
||||
pages: ["cli", "build", "index", "config", "configure", "migrate-v1", "troubleshooting"],
|
||||
})
|
||||
+12
-12
@@ -4,25 +4,25 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run generate && blume dev --host --port 3000",
|
||||
"build": "bun run generate && blume build && bun script/prepare-cloudflare.ts",
|
||||
"dev": "bun run generate && astro dev --host --port 3000",
|
||||
"build": "bun run generate && astro build && bun script/pagefind.ts && bun run validate && bun script/prepare-cloudflare.ts",
|
||||
"deploy": "wrangler deploy --config dist/server/wrangler.json",
|
||||
"generate": "bun script/generate-theme-tokens.ts && bun script/generate-openapi.ts",
|
||||
"check:generated": "bun script/generate-theme-tokens.ts --check && bun script/generate-openapi.ts --check",
|
||||
"typecheck": "blume check",
|
||||
"validate": "blume validate",
|
||||
"doctor": "blume doctor"
|
||||
"generate": "bun script/generate-openapi.ts",
|
||||
"check:generated": "bun script/generate-openapi.ts --check",
|
||||
"validate": "bun script/validate-links.ts",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"blume": "1.5.1"
|
||||
"@astrojs/mdx": "7.0.5",
|
||||
"astro": "7.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "14.1.4",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@astrojs/check": "0.9.6",
|
||||
"@astrojs/markdown-remark": "7.2.4",
|
||||
"@types/bun": "catalog:",
|
||||
"astro": "7.1.3",
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"pagefind": "1.5.2",
|
||||
"typescript": "catalog:",
|
||||
"wrangler": "4.110.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
export const prerender = false
|
||||
|
||||
return Astro.redirect(`${import.meta.env.BASE_URL}docs`, 308)
|
||||
---
|
||||
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 297 B |
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Schema, SchemaAST } from "effect"
|
||||
import { format } from "prettier"
|
||||
import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
|
||||
|
||||
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
|
||||
const root = requireObject(ThemeDefinition.ast)
|
||||
const hue = requireObject(requireField(root, "hue").type)
|
||||
const hueNames = hue.propertySignatures.map((field) => String(field.name))
|
||||
const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) =>
|
||||
String(field.name),
|
||||
)
|
||||
const contexts = root.propertySignatures
|
||||
.map((field) => String(field.name))
|
||||
.filter((name) => name.startsWith("@context:"))
|
||||
const tokens = root.propertySignatures
|
||||
.filter((field) => {
|
||||
const name = String(field.name)
|
||||
return name !== "hue" && name !== "categorical" && !name.startsWith("@context:")
|
||||
})
|
||||
.flatMap((field) => tokenPaths(field.type, String(field.name)))
|
||||
const groups = Map.groupBy(tokens, (token) =>
|
||||
token
|
||||
.split(".")
|
||||
.slice(0, token.split(".").length > 2 ? 2 : 1)
|
||||
.join("."),
|
||||
)
|
||||
const table = [...groups]
|
||||
.map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("<br />")} |`)
|
||||
.join("\n")
|
||||
const example = {
|
||||
version: 2,
|
||||
light: {
|
||||
hue: {
|
||||
accent: "$hue.purple",
|
||||
interactive: "$hue.purple",
|
||||
},
|
||||
text: {
|
||||
default: "$hue.neutral.900",
|
||||
},
|
||||
background: {
|
||||
default: "#fafafa",
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
mergeMode: true,
|
||||
text: {
|
||||
default: "$hue.neutral.100",
|
||||
},
|
||||
background: {
|
||||
default: "#101014",
|
||||
},
|
||||
},
|
||||
} satisfies ThemeDocument
|
||||
Schema.decodeUnknownSync(ThemeDocument)(example)
|
||||
const output = await format(
|
||||
`{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
|
||||
|
||||
\`\`\`json title="my-theme.json"
|
||||
${JSON.stringify(example, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Token reference
|
||||
|
||||
This reference is generated from the Effect schema in
|
||||
\`@opencode-ai/theme/tui\`. Changes to the runtime schema update
|
||||
this section through \`bun run generate\`.
|
||||
|
||||
### Hue tokens
|
||||
|
||||
Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these
|
||||
steps, or alias it to another hue with a value such as \`$hue.blue\`.
|
||||
|
||||
| | Values |
|
||||
| --- | --- |
|
||||
| Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} |
|
||||
| Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} |
|
||||
|
||||
Reference a hue color as \`$hue.<name>.<step>\`, for example
|
||||
\`$hue.interactive.500\`.
|
||||
|
||||
### Semantic tokens
|
||||
|
||||
Semantic values can reference another token by prefixing its path with \`$\`,
|
||||
for example \`$text.default\`. Stateful tokens inherit their \`default\`
|
||||
value when a state is omitted.
|
||||
|
||||
| Group | Tokens |
|
||||
| --- | --- |
|
||||
${table}
|
||||
|
||||
### Contexts
|
||||
|
||||
${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial
|
||||
overrides of the semantic tokens above. Components apply these contexts to
|
||||
surfaces that need different contrast without changing the base theme.
|
||||
`,
|
||||
{ parser: "mdx", printWidth: 120, semi: false },
|
||||
)
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = await Bun.file(target).text()
|
||||
if (current === output) process.exit(0)
|
||||
console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/www.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Bun.write(target, output)
|
||||
|
||||
function requireObject(ast: SchemaAST.AST): SchemaAST.Objects {
|
||||
if (SchemaAST.isObjects(ast)) return ast
|
||||
if (SchemaAST.isUnion(ast)) {
|
||||
const object = ast.types.map(findObject).find((value) => value !== undefined)
|
||||
if (object) return object
|
||||
}
|
||||
throw new Error(`Expected an object schema, received ${ast._tag}`)
|
||||
}
|
||||
|
||||
function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined {
|
||||
if (SchemaAST.isObjects(ast)) return ast
|
||||
if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined)
|
||||
if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk())
|
||||
}
|
||||
|
||||
function requireField(ast: SchemaAST.Objects, name: string) {
|
||||
const field = ast.propertySignatures.find((field) => String(field.name) === name)
|
||||
if (field) return field
|
||||
throw new Error(`Theme schema field not found: ${name}`)
|
||||
}
|
||||
|
||||
function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] {
|
||||
const object = findObject(ast)
|
||||
if (!object || object.propertySignatures.length === 0) return [prefix]
|
||||
return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import config from "../astro.config"
|
||||
|
||||
const base = config.base?.replace(/\/$/, "") ?? ""
|
||||
|
||||
await Bun.$`pagefind --site ${`dist/client${base}/docs`}`
|
||||
@@ -0,0 +1,39 @@
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../dist/client")
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: root, absolute: true }))
|
||||
const failures = (
|
||||
await Promise.all(
|
||||
files.flatMap(async (file) => {
|
||||
const html = await Bun.file(file).text()
|
||||
const page = `https://opencode.local/${path.relative(root, file).replace(/index\.html$/, "")}`
|
||||
return Promise.all(
|
||||
Array.from(html.matchAll(/href="([^"]+)"/g), async (match) => {
|
||||
const url = new URL(match[1], page)
|
||||
if (url.origin !== "https://opencode.local") return
|
||||
|
||||
const targetPath = path.join(root, decodeURIComponent(url.pathname))
|
||||
const target = (
|
||||
await Promise.all(
|
||||
[targetPath, path.join(targetPath, "index.html"), `${targetPath}.html`].map(async (candidate) =>
|
||||
(await Bun.file(candidate).exists()) ? candidate : undefined,
|
||||
),
|
||||
)
|
||||
).find((candidate) => candidate !== undefined)
|
||||
if (!target) return `${path.relative(root, file)}: missing ${url.pathname}`
|
||||
if (!url.hash || !target.endsWith(".html")) return
|
||||
|
||||
const id = decodeURIComponent(url.hash.slice(1))
|
||||
if ((await Bun.file(target).text()).includes(`id="${id}"`)) return
|
||||
return `${path.relative(root, file)}: missing ${url.pathname}${url.hash}`
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat(2)
|
||||
.filter((failure) => failure !== undefined)
|
||||
|
||||
if (failures.length === 0) process.exit(0)
|
||||
console.error([...new Set(failures)].sort().join("\n"))
|
||||
process.exit(1)
|
||||
@@ -1,79 +0,0 @@
|
||||
{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
|
||||
|
||||
```json title="my-theme.json"
|
||||
{
|
||||
"version": 2,
|
||||
"light": {
|
||||
"hue": {
|
||||
"accent": "$hue.purple",
|
||||
"interactive": "$hue.purple"
|
||||
},
|
||||
"text": {
|
||||
"default": "$hue.neutral.900"
|
||||
},
|
||||
"background": {
|
||||
"default": "#fafafa"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"mergeMode": true,
|
||||
"text": {
|
||||
"default": "$hue.neutral.100"
|
||||
},
|
||||
"background": {
|
||||
"default": "#101014"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token reference
|
||||
|
||||
This reference is generated from the Effect schema in
|
||||
`@opencode-ai/theme/tui`. Changes to the runtime schema update
|
||||
this section through `bun run generate`.
|
||||
|
||||
### Hue tokens
|
||||
|
||||
Every hue is a 9-step scale. Define a scale with all of these
|
||||
steps, or alias it to another hue with a value such as `$hue.blue`.
|
||||
|
||||
| | Values |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Hues | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `accent`, `interactive`, `neutral` |
|
||||
| Steps | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` |
|
||||
|
||||
Reference a hue color as `$hue.<name>.<step>`, for example
|
||||
`$hue.interactive.500`.
|
||||
|
||||
### Semantic tokens
|
||||
|
||||
Semantic values can reference another token by prefixing its path with `$`,
|
||||
for example `$text.default`. Stateful tokens inherit their `default`
|
||||
value when a state is omitted.
|
||||
|
||||
| Group | Tokens |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `text` | `text.default`<br />`text.subdued` |
|
||||
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.secondary.default`<br />`text.action.secondary.$hovered`<br />`text.action.secondary.$focused`<br />`text.action.secondary.$pressed`<br />`text.action.secondary.$selected`<br />`text.action.secondary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
|
||||
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
|
||||
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
|
||||
| `background` | `background.default` |
|
||||
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
|
||||
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.secondary.default`<br />`background.action.secondary.$hovered`<br />`background.action.secondary.$focused`<br />`background.action.secondary.$pressed`<br />`background.action.secondary.$selected`<br />`background.action.secondary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
|
||||
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
|
||||
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
|
||||
| `border` | `border.default` |
|
||||
| `scrollbar` | `scrollbar.default` |
|
||||
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
|
||||
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
|
||||
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
|
||||
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
|
||||
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
|
||||
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
|
||||
|
||||
### Contexts
|
||||
|
||||
`@context:elevated` and `@context:overlay` accept partial
|
||||
overrides of the semantic tokens above. Components apply these contexts to
|
||||
surfaces that need different contrast without changing the base theme.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineCollection } from "astro:content"
|
||||
import { glob } from "astro/loaders"
|
||||
import { z } from "astro/zod"
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
loader: glob({
|
||||
base: "./src/docs/content",
|
||||
pattern: "**/*.{md,mdx}",
|
||||
}),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
tableOfContents: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z.object({
|
||||
minHeadingLevel: z.number().int().min(1).max(6).optional(),
|
||||
maxHeadingLevel: z.number().int().min(1).max(6).optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Documentation feature guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This folder owns the documentation data model, content, frontend, and styling.
|
||||
- Documentation content lives in `content/` and is loaded by `../content.config.ts`.
|
||||
- `lib/navigation.ts` is the source of truth for header sections and sidebar ordering.
|
||||
- The thin route entrypoints live in `../pages/docs/`; keep docs implementation details here.
|
||||
- Do not add a documentation frontend framework; this folder owns the UI directly.
|
||||
- Keep internal Markdown links docs-root-relative, for example `/config`; `remark-links.ts` applies the site and docs base paths.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun typecheck` and `bun run build` from `packages/www` after changes.
|
||||
- Check desktop and mobile layouts when changing navigation or shared styles.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
---
|
||||
interface Props {
|
||||
type?: "note" | "tip" | "warning"
|
||||
title?: string
|
||||
}
|
||||
|
||||
const type = Astro.props.type ?? "note"
|
||||
const title = Astro.props.title ?? (type === "tip" ? "Tip" : type === "warning" ? "Warning" : "Note")
|
||||
---
|
||||
|
||||
<aside class={`callout callout-${type}`} aria-label={title}>
|
||||
<strong>{title}</strong>
|
||||
<div><slot /></div>
|
||||
</aside>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user