mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 10:06:09 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd6600afc |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: canary # Bun 1.4 until its stable release is published
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
BUN_COMPILE_RELEASE: canary
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
@@ -195,33 +195,9 @@ jobs:
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-app-archive:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build app archive
|
||||
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache/app-archive.bin
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -234,7 +210,6 @@ jobs:
|
||||
host: macos-26
|
||||
- target: windows-arm64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
bun_install_flags: --cpu=*
|
||||
- target: windows-x64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
@@ -246,19 +221,14 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
install-flags: ${{ matrix.settings.bun_install_flags }}
|
||||
install-flags: --os=* --cpu=*
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache
|
||||
|
||||
- name: Build
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
@@ -573,7 +543,6 @@ jobs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-app-archive
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-tdNjQEuCMADQJpeAK0BajPyQkt8X2pS9foyQYxj+vgg=",
|
||||
"aarch64-linux": "sha256-RXvYaZXpgTyZzgPu74fat1bbT9zJUf+S0kpm9rXtr6k=",
|
||||
"aarch64-darwin": "sha256-MDinXCNrEn5pH75nmK/dsl78hcS4gu913Sf2fxj40r0=",
|
||||
"x86_64-darwin": "sha256-pn9t+1sNDjQh25nfPozD11aIJJsHgxhU9uB4tR0UnJk="
|
||||
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
|
||||
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
|
||||
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
|
||||
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -49,10 +49,10 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"ulid": "3.0.1",
|
||||
@@ -174,7 +174,7 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
interface ParserState {
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly nextToolCallId: number
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
@@ -579,6 +580,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const events: LLMEvent[] = []
|
||||
let hasToolCalls = nextState.hasToolCalls
|
||||
let lifecycle = nextState.lifecycle
|
||||
let nextToolCallId = nextState.nextToolCallId
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
|
||||
@@ -618,9 +620,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
@@ -649,6 +649,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
...nextState,
|
||||
hasToolCalls,
|
||||
lifecycle,
|
||||
nextToolCallId,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
@@ -672,7 +673,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
|
||||
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
onHalt: finish,
|
||||
},
|
||||
|
||||
@@ -8,4 +8,3 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenAIResponses from "./openai-responses.js"
|
||||
export * as OpenResponses from "./open-responses.js"
|
||||
export * as OpenResponsesChannel from "./open-responses-channel.js"
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "../route/transport/index.js"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
|
||||
|
||||
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
@@ -26,6 +25,11 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly driver?: (input: {
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
}) => WebSocketChannelDriver
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -154,13 +158,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: OpenResponsesContinuation.driver({
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
}),
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
|
||||
@@ -42,12 +42,8 @@ const OpenResponsesInputImage = Schema.Struct({
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
file_data: Schema.optional(Schema.String),
|
||||
file_url: Schema.optional(Schema.String),
|
||||
})
|
||||
const OpenResponsesInputVideo = Schema.Struct({
|
||||
type: Schema.tag("input_video"),
|
||||
video_url: Schema.String,
|
||||
file_data: Schema.String,
|
||||
mime_type: Schema.optional(Schema.String),
|
||||
})
|
||||
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
@@ -58,14 +54,9 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -90,7 +81,6 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
|
||||
OpenResponsesInputText,
|
||||
OpenResponsesInputImage,
|
||||
OpenResponsesInputFile,
|
||||
OpenResponsesInputVideo,
|
||||
])
|
||||
|
||||
const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
@@ -247,7 +237,6 @@ const OpenResponsesErrorPayload = Schema.Struct({
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
@@ -312,6 +301,20 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
const RefusalEvent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.delta"),
|
||||
item_id: Schema.String,
|
||||
delta: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.done"),
|
||||
item_id: Schema.String,
|
||||
refusal: Schema.String,
|
||||
}),
|
||||
])
|
||||
const isRefusalEvent = Schema.is(RefusalEvent)
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -320,6 +323,7 @@ export interface Extension {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -332,6 +336,7 @@ export interface ParserState {
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
@@ -409,29 +414,26 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
}
|
||||
}
|
||||
|
||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||
return itemID(part.providerMetadata, providerMetadataKey)
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
target: "message" | "tool-result",
|
||||
) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
: undefined
|
||||
if (!media.mime.startsWith("image/")) {
|
||||
if (target === "tool-result" && media.mime.startsWith("video/"))
|
||||
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
...(url ? { file_url: url } : { file_data: media.base64 }),
|
||||
file_data: media.dataUrl,
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
@@ -440,17 +442,10 @@ const lowerUserContent = Effect.fnUntraced(function* (
|
||||
extension: Extension,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
|
||||
if (part.type === "media") return yield* lowerMedia(part, request, extension)
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
||||
})
|
||||
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
|
||||
const lowered = yield* lowerMedia(part, request, extension, "message")
|
||||
if (lowered.type === "input_video")
|
||||
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
|
||||
return lowered
|
||||
})
|
||||
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
@@ -463,20 +458,6 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
"tool-result",
|
||||
)
|
||||
})
|
||||
|
||||
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMessageMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -529,7 +510,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ id, phase, parts: [part] })
|
||||
@@ -579,19 +560,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id))
|
||||
input.push({ type: "item_reference", id })
|
||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
})
|
||||
}
|
||||
if (id) hostedToolReferences.add(id)
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -803,17 +782,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id) {
|
||||
const phase = messagePhase(item.phase)
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -971,7 +951,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
@@ -1079,26 +1059,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// Build a single human-readable message from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||
// the bare message — production rate limits and context-length failures used
|
||||
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||
// the payload carries no usable summary.
|
||||
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||
// to be indistinguishable from generic stream drops.
|
||||
const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
// Keep the full raw payload on the error even when the message is a summary.
|
||||
const body = JSON.stringify(nested ?? event) ?? ""
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
@@ -1108,8 +1084,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
body,
|
||||
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1125,18 +1100,21 @@ export const step = (state: ParserState, event: Event) => {
|
||||
)
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
|
||||
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
@@ -1181,11 +1159,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
if (value === "commentary" || value === "final_answer") return value
|
||||
return extension.messagePhase?.(value)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
|
||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||
return Effect.undefined
|
||||
return Effect.succeed(undefined)
|
||||
})
|
||||
const multipartMask =
|
||||
mask === undefined
|
||||
|
||||
+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 = "open-responses.websocket.v1"
|
||||
const PROTOCOL = "openai-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
@@ -161,4 +161,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
export const OpenAIResponsesChannel = { driver } as const
|
||||
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -39,8 +40,20 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -54,6 +67,16 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
type: "input_file",
|
||||
filename: part.filename ?? "document.pdf",
|
||||
file_data: media.base64,
|
||||
mime_type: media.mime,
|
||||
}
|
||||
},
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -105,7 +128,46 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
} satisfies OpenAIResponsesBody
|
||||
})
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
type HostedToolData = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
readonly results?: unknown
|
||||
readonly code?: string
|
||||
readonly container_id?: string
|
||||
readonly outputs?: unknown
|
||||
readonly server_label?: string
|
||||
readonly output?: unknown
|
||||
readonly result?: string
|
||||
readonly output_format?: "png" | "jpeg" | "webp"
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
|
||||
|
||||
type HostedToolType = keyof typeof HOSTED_TOOLS
|
||||
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
|
||||
|
||||
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
|
||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
@@ -126,22 +188,32 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
|
||||
state: OpenResponses.ParserState,
|
||||
item: HostedToolItem,
|
||||
) {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: yield* hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
@@ -152,8 +224,8 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
}
|
||||
|
||||
@@ -175,7 +247,11 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
|
||||
export const channelTransport = (options: Omit<Options, "driver">) =>
|
||||
OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
...options,
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
|
||||
})
|
||||
export const transport = channelTransport({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
|
||||
url: string,
|
||||
module: string,
|
||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||
if (!url.startsWith("data:")) return Effect.undefined
|
||||
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { Lifecycle } from "./lifecycle.js"
|
||||
|
||||
export type Item = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
readonly results?: unknown
|
||||
readonly code?: string
|
||||
readonly container_id?: string
|
||||
readonly outputs?: unknown
|
||||
readonly server_label?: string
|
||||
readonly output?: unknown
|
||||
readonly result?: string
|
||||
readonly output_format?: "png" | "jpeg" | "webp"
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly input: (item: Item) => unknown
|
||||
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
|
||||
}
|
||||
|
||||
export type Definitions = Readonly<Record<string, Definition>>
|
||||
|
||||
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
|
||||
item.type in tools && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
export const onDone: (
|
||||
state: OpenResponses.ParserState,
|
||||
item: Item,
|
||||
tools: Definitions,
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
|
||||
state,
|
||||
item,
|
||||
tools,
|
||||
) {
|
||||
const tool = tools[item.type]
|
||||
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: tool.result
|
||||
? yield* tool.result(item)
|
||||
: item.error !== undefined && item.error !== null
|
||||
? { type: "error", value: item.error }
|
||||
: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
const NAME = "xAI Responses"
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
return OpenResponses.step(state, event)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: OpenResponses.protocol.body,
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
})
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
@@ -80,9 +80,6 @@ export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
|
||||
// that the summary message does not carry. Not shown to users.
|
||||
readonly rawBody?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
@@ -92,13 +89,11 @@ export interface ProviderFailure {
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const body = input.http?.body ?? input.rawBody ?? ""
|
||||
const body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
// Scan the raw payload too so signals missing from the summary message
|
||||
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||
const text = body || input.message
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
@@ -35,9 +34,9 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
|
||||
@@ -153,9 +153,6 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
// Raw provider payload as a string, so classified failures never lose the
|
||||
// original error detail even when the pretty message is a summary.
|
||||
body: Schema.optional(Schema.String),
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/openai-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:36.438Z"
|
||||
},
|
||||
@@ -21,7 +14,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
|
||||
"name": "pdf/openai-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:34.867Z"
|
||||
},
|
||||
@@ -20,7 +14,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/xai-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:43.608Z",
|
||||
"protocol": "xai-responses"
|
||||
"recordedAt": "2026-07-22T18:15:43.608Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,7 +14,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
|
||||
"name": "pdf/xai-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:42.429Z",
|
||||
"protocol": "xai-responses"
|
||||
"recordedAt": "2026-07-22T18:15:42.429Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,7 +14,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"},{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -106,20 +106,3 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider error rawBody classification", () => {
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
|
||||
})
|
||||
expect(reason._tag).toBe("InvalidRequest")
|
||||
expect(reason).toMatchObject({ classification: "context-overflow" })
|
||||
})
|
||||
|
||||
test("extracts nested codes from the raw payload", () => {
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
|
||||
).toBe("QuotaExceeded")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -848,7 +848,7 @@ describe("Gemini route", () => {
|
||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||
})
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call",
|
||||
id: "tool_0",
|
||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
@@ -862,14 +862,14 @@ describe("Gemini route", () => {
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||
ToolCallPart.make({
|
||||
id: "provider_call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "provider_call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
result: "done",
|
||||
resultType: "text",
|
||||
@@ -1101,17 +1101,21 @@ describe("Gemini route", () => {
|
||||
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
||||
})
|
||||
|
||||
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls[0]).toMatchObject({
|
||||
type: "tool-call",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
})
|
||||
expect(response.toolCalls).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: response.toolCalls[0].id,
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
@@ -1154,8 +1158,7 @@ describe("Gemini route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
|
||||
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1195,7 +1198,7 @@ describe("Gemini route", () => {
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "news" } } },
|
||||
],
|
||||
},
|
||||
@@ -1209,20 +1212,16 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls[0]).toMatchObject({
|
||||
type: "tool-call",
|
||||
id: "call_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "call_0" } },
|
||||
})
|
||||
expect(response.toolCalls[1]).toMatchObject({
|
||||
type: "tool-call",
|
||||
name: "lookup",
|
||||
input: { query: "news" },
|
||||
})
|
||||
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
|
||||
expect(response.toolCalls).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "tool_0" } },
|
||||
},
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
@@ -1230,31 +1229,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns distinct unique fallback ids across separate requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const req = LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
})
|
||||
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
|
||||
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps length and content-filter finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const length = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -113,9 +113,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
|
||||
],
|
||||
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.js"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
|
||||
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -68,7 +68,7 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
return OpenAIResponsesChannel.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
@@ -1273,7 +1273,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1300,12 +1300,12 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: base64 },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
|
||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1334,6 +1334,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1358,61 +1359,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers remote tool-result media URLs without base64 wrapping", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "fetch",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "file", uri: "https://example.com/image.png", mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.com/report.pdf", mime: "application/pdf", name: "report.pdf" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers tool-result videos as input_video", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "record", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "record",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "file", uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" },
|
||||
{ type: "file", uri: "https://example.com/demo.mp4", mime: "video/mp4" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_video", video_url: "data:video/mp4;base64,AAECAw==" },
|
||||
{ type: "input_video", video_url: "https://example.com/demo.mp4" },
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2765,7 +2712,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2773,7 +2720,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses standard inline file encoding for xAI user PDFs", () =>
|
||||
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -2797,6 +2744,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2821,7 +2769,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "AAECAw==",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2829,37 +2777,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers remote user media URLs without base64 wrapping", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "https://example.com/report.pdf",
|
||||
filename: "report.pdf",
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed rate limit for provider error frames", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
@@ -3016,42 +2933,36 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the raw payload when error is null", () =>
|
||||
it.effect("falls back to a stable default when error is null", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"error":null')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the raw payload when both error and response are absent", () =>
|
||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
|
||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
|
||||
id: "xai",
|
||||
name: "xAI Grok 4.5",
|
||||
provider: "xai",
|
||||
protocol: "xai-responses",
|
||||
protocol: "openai-responses",
|
||||
requires: "XAI_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 40,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("extends the Open Responses baseline directly", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
|
||||
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||
expect(prepared.protocol).toBe("xai-responses")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI reasoning text events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
|
||||
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
|
||||
type: "reasoning",
|
||||
text: "Considering.",
|
||||
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "x_search_1",
|
||||
name: "x_search",
|
||||
input: { query: "news" },
|
||||
providerExecuted: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -19,12 +19,6 @@
|
||||
|
||||
- Always prefer `createStore` over multiple `createSignal` calls
|
||||
|
||||
## Typography
|
||||
|
||||
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
|
||||
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
|
||||
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
|
||||
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
|
||||
@@ -259,34 +259,6 @@ export function event(
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
|
||||
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
|
||||
return makeEvent("session.compaction.started", data)
|
||||
}
|
||||
|
||||
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
|
||||
return makeEvent("session.compaction.delta", data)
|
||||
}
|
||||
|
||||
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
|
||||
return makeEvent("session.compaction.ended", data)
|
||||
}
|
||||
|
||||
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
|
||||
return makeEvent("session.compaction.failed", data)
|
||||
}
|
||||
|
||||
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
|
||||
return makeEvent("session.tool.input.started", data)
|
||||
}
|
||||
|
||||
export function toolInputEnded(data: Extract<OpenCodeEvent, { type: "session.tool.input.ended" }>["data"]) {
|
||||
return makeEvent("session.tool.input.ended", data)
|
||||
}
|
||||
|
||||
export function toolCalled(data: Extract<OpenCodeEvent, { type: "session.tool.called" }>["data"]) {
|
||||
return makeEvent("session.tool.called", data)
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
|
||||
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
|
||||
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
@@ -14,63 +13,10 @@ import {
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("follows an expanded patch that arrives as the user reaches the bottom", async ({ page }) => {
|
||||
const toolID = "prt_bottom_follow_patch"
|
||||
const input = { patchText: "Update src/edit.ts" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(20),
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_bottom_follow_text", "Working")], { completed: false }),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 300)
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 300 }))
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
await timeline.send(partUpdated(toolPart(toolID, "patch", "running", input)))
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(toolID, "patch", "completed", input, {
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/edit.ts",
|
||||
status: "modified",
|
||||
patch: createTwoFilesPatch(
|
||||
"a/src/edit.ts",
|
||||
"b/src/edit.ts",
|
||||
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index}\n`).join(""),
|
||||
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index + 1}\n`).join(""),
|
||||
),
|
||||
additions: 40,
|
||||
deletions: 40,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await timeline.waitForPart(toolID)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_wheel_01_shell"
|
||||
const followingID = "prt_wheel_02_following"
|
||||
|
||||
@@ -53,37 +53,6 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, { draftID })
|
||||
await page.goto("/")
|
||||
|
||||
const draftHref = stressDraftHref(draftID)
|
||||
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
|
||||
await expect(draftTab).toHaveCount(1)
|
||||
await draftTab.click()
|
||||
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
|
||||
await expect(sessionTab).toHaveCount(1)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: '[data-component="new-session"]',
|
||||
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await sessionTab.click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
|
||||
@@ -45,17 +45,21 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a diff line is hovered", async ({ page }) => {
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const first = 1", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(comment).toHaveCount(1)
|
||||
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.dispatchEvent("click")
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await lineNumber.hover()
|
||||
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||
await expect(comment).toHaveCount(1)
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.focus()
|
||||
await expect(comment).toBeFocused()
|
||||
}).toPass({ timeout: 10_000 })
|
||||
await comment.press("Enter")
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
+6
-49
@@ -18,7 +18,7 @@ const branchDiffs = [
|
||||
),
|
||||
]
|
||||
|
||||
test("uses side placement by default and supports the terminal across the bottom", async ({ page }) => {
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -27,7 +27,7 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-terminal-bottom",
|
||||
name: "review-terminal-stacked",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
@@ -45,7 +45,7 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-terminal-bottom",
|
||||
slug: "review-terminal-stacked",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
@@ -138,22 +138,7 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectSideGeometry(page)
|
||||
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
|
||||
)
|
||||
})
|
||||
await page.reload()
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
await expectBottomGeometry(page)
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
async function expectTree(page: Page, total: number, file: string) {
|
||||
@@ -178,51 +163,23 @@ async function expectMountedTree(page: Page, total: number) {
|
||||
expect(state.rows).toBeLessThanOrEqual(60)
|
||||
}
|
||||
|
||||
async function expectSideGeometry(page: Page) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
|
||||
return {
|
||||
reviewLeft: review.left,
|
||||
reviewRight: review.right,
|
||||
terminalLeft: terminal.left,
|
||||
terminalRight: terminal.right,
|
||||
terminalTop: terminal.top,
|
||||
reviewTop: review.top,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
|
||||
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
|
||||
}
|
||||
|
||||
async function expectBottomGeometry(page: Page) {
|
||||
async function expectStackGeometry(page: Page) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
const terminalRect = terminal.getBoundingClientRect()
|
||||
const reviewParent = review.parentElement!.getBoundingClientRect()
|
||||
const terminalParent = terminal.parentElement!.getBoundingClientRect()
|
||||
const sidebar = review.querySelector<HTMLElement>('[data-slot="session-review-v2-sidebar"]')!
|
||||
return {
|
||||
review: review.getBoundingClientRect().height,
|
||||
reviewBottom: review.getBoundingClientRect().bottom,
|
||||
reviewParent: reviewParent.height,
|
||||
terminal: terminalRect.height,
|
||||
terminalLeft: terminalRect.left,
|
||||
terminalRight: terminalRect.right,
|
||||
terminalTop: terminalRect.top,
|
||||
terminal: terminal.getBoundingClientRect().height,
|
||||
terminalParent: terminalParent.height,
|
||||
sidebar: sidebar.getBoundingClientRect().width,
|
||||
viewport: window.innerWidth,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
|
||||
expect(geometry.terminalLeft).toBeLessThanOrEqual(9)
|
||||
expect(geometry.terminalRight).toBeGreaterThanOrEqual(geometry.viewport - 9)
|
||||
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, test, type Route } from "@playwright/test"
|
||||
|
||||
const server = "http://127.0.0.1:4097"
|
||||
|
||||
test("nested server dialog keeps focus inside the top layer", async ({ page }) => {
|
||||
await page.addInitScript((server) => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] }))
|
||||
}, server)
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === "/api/event") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/global/health" || url.pathname === "/api/health") {
|
||||
return json(route, { healthy: true, version: "2.0.0" })
|
||||
}
|
||||
return json(route, {})
|
||||
})
|
||||
|
||||
await page.goto("/")
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.locator(".settings-dialog")
|
||||
await expect(settings).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Servers" }).click()
|
||||
await settings.getByRole("button", { name: "Add server" }).click()
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
const username = editor.getByPlaceholder("username")
|
||||
const password = editor.getByPlaceholder("password")
|
||||
await username.click()
|
||||
await expect(username).toBeFocused()
|
||||
await username.fill("kit")
|
||||
await expect(username).toHaveValue("kit")
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(password).toBeFocused()
|
||||
await password.fill("secret")
|
||||
await expect(password).toHaveValue("secret")
|
||||
})
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) })
|
||||
}
|
||||
@@ -99,7 +99,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await wrapper.evaluate((element) => {
|
||||
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
|
||||
})
|
||||
await wrapper.locator('[data-scope="apply-patch"] button').click()
|
||||
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||
await expectExpanded(wrapper, false)
|
||||
|
||||
events.push(...textEvents())
|
||||
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
|
||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
@@ -179,8 +179,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
const trigger = wrapper.locator('[data-component="sticky-accordion-header"]')
|
||||
const diff = wrapper.locator('[data-component="apply-patch-file-diff"]').first()
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const diff = wrapper.locator('[data-component="edit-content"]').first()
|
||||
await expectAppVisible(diff)
|
||||
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
|
||||
const samples = await wrapper.evaluate(async (element) => {
|
||||
@@ -190,8 +190,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
for (const offset of [0, 120, 240, 360, 480]) {
|
||||
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
|
||||
await new Promise(requestAnimationFrame)
|
||||
const trigger = element.querySelector<HTMLElement>('[data-component="sticky-accordion-header"]')!
|
||||
const diff = element.querySelector<HTMLElement>('[data-component="apply-patch-file-diff"]')!
|
||||
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
|
||||
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
|
||||
result.push({
|
||||
offset,
|
||||
trigger: trigger.getBoundingClientRect().y,
|
||||
@@ -202,7 +202,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
return result
|
||||
})
|
||||
|
||||
expect(samples[0]!.trigger).toBeGreaterThanOrEqual(samples[0]!.diff)
|
||||
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
|
||||
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
|
||||
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
|
||||
})
|
||||
@@ -234,9 +234,7 @@ async function readToolState(page: Page) {
|
||||
.evaluate(
|
||||
(element, textPartID) => ({
|
||||
expanded: (() => {
|
||||
const trigger =
|
||||
element.querySelector('[data-scope="apply-patch"] button') ??
|
||||
element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
if (aria === "true") return true
|
||||
if (aria === "false") return false
|
||||
@@ -411,9 +409,7 @@ function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
const trigger =
|
||||
element.querySelector('[data-scope="apply-patch"] button') ??
|
||||
element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
if (aria === "true") return true
|
||||
if (aria === "false") return false
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
@@ -54,144 +47,5 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const file = wrapper.locator('[data-scope="apply-patch"]')
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
await file.getByRole("button").click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("keeps an expanded file diff header at the same viewport position", async ({ page }) => {
|
||||
const id = "prt_file_projection_anchored_patch"
|
||||
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
||||
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage([userText("Preceding context ".repeat(120))]),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update src/anchored.ts" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/anchored.ts",
|
||||
status: "modified",
|
||||
patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after),
|
||||
additions: 80,
|
||||
deletions: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
viewport: { width: 1200, height: 600 },
|
||||
})
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const row = page.locator("[data-timeline-key]", { has: wrapper })
|
||||
const trigger = wrapper.getByRole("button")
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
return measured
|
||||
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
|
||||
: Number.POSITIVE_INFINITY
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(1)
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
|
||||
})
|
||||
await expect(trigger).toBeInViewport()
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -20)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
|
||||
.toBeGreaterThan(0)
|
||||
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element, collapsed) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
const allocatedHeight = element.getBoundingClientRect().height
|
||||
return {
|
||||
grew: allocatedHeight > collapsed + 1,
|
||||
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
|
||||
}
|
||||
}, collapsedHeight),
|
||||
)
|
||||
.toEqual({ grew: true, measured: true })
|
||||
await expect
|
||||
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
|
||||
.toBeLessThanOrEqual(5)
|
||||
|
||||
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
|
||||
.toBeGreaterThan(50)
|
||||
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
|
||||
expect(scrolled).toBeLessThan(400)
|
||||
|
||||
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY)
|
||||
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
return measured
|
||||
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
|
||||
: Number.POSITIVE_INFINITY
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
|
||||
test("keeps patch file disclosures independent", async ({ page }) => {
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
|
||||
await setupTimeline(page, {
|
||||
@@ -21,17 +21,15 @@ test("keeps patch file disclosures independent", async ({ page }) => {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await modified.getByRole("button").click()
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "false")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 20
|
||||
const messagePageSize = 200
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
@@ -9,13 +8,9 @@ import {
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
sessionID,
|
||||
status,
|
||||
stepStarted,
|
||||
textPart,
|
||||
toolCalled,
|
||||
toolInputEnded,
|
||||
toolInputStarted,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
@@ -39,56 +34,7 @@ for (const expanded of [false, true]) {
|
||||
})
|
||||
}
|
||||
|
||||
test("transitions a streaming shell from writing through command execution", async ({ page }) => {
|
||||
const id = "prt_shell_streaming_input"
|
||||
const command = "printf ready"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
})
|
||||
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
|
||||
const titleShimmer = title.locator('[data-component="text-shimmer"]')
|
||||
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(titleShimmer).toHaveAttribute("aria-label", "Shell")
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(subtitle).toHaveText("Writing command...")
|
||||
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
|
||||
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
|
||||
await expect(title).toHaveCSS("font-size", "13px")
|
||||
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
|
||||
await expect(title).toHaveCSS("font-weight", "530")
|
||||
await expect(title).toHaveCSS("line-height", "16px")
|
||||
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
|
||||
await expect(subtitle).toHaveCSS("font-size", "13px")
|
||||
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
|
||||
await expect(subtitle).toHaveCSS("font-weight", "440")
|
||||
await expect(subtitle).toHaveCSS("line-height", "16px")
|
||||
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
|
||||
|
||||
const input = JSON.stringify({ command })
|
||||
await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input }))
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(subtitle).toHaveText(command)
|
||||
await expect(tool).not.toContainText("Writing command...")
|
||||
|
||||
await timeline.send(
|
||||
toolCalled({
|
||||
sessionID,
|
||||
assistantMessageID: assistantID,
|
||||
id,
|
||||
input: { command },
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(subtitle).toHaveText(command)
|
||||
})
|
||||
|
||||
test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
||||
const id = "prt_shell_running_command"
|
||||
const command = "sleep 10 && echo done"
|
||||
await setupTimeline(page, {
|
||||
@@ -98,10 +44,8 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(tool).not.toContainText("Writing command...")
|
||||
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
||||
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
|
||||
await tool.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
compactionDelta,
|
||||
compactionEnded,
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
@@ -82,160 +73,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(
|
||||
compactionStarted({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
|
||||
await timeline.send(
|
||||
compactionDelta({
|
||||
sessionID,
|
||||
text: "## Checkpoint\n\nStreamed implementation details.",
|
||||
}),
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
|
||||
await timeline.send(
|
||||
compactionEnded({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "## Checkpoint\n\nFinal implementation details.",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
await expect(compaction).toContainText("Final implementation details.")
|
||||
await expect(compaction).not.toContainText("Streamed implementation details.")
|
||||
})
|
||||
|
||||
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(compactions).toHaveCount(2)
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
})
|
||||
|
||||
test("shows a delegating row while subagent input streams", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
...assistant(false),
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const delegating = page.locator('[data-component="task-tool-delegating"]')
|
||||
await expect(delegating).toBeVisible()
|
||||
const shimmer = delegating.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
|
||||
await expect(shimmer).toHaveCSS("line-height", "16px")
|
||||
const icon = delegating.locator('[data-slot="icon-svg"]')
|
||||
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
|
||||
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
|
||||
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
|
||||
await page.setViewportSize({ width: 480, height: 720 })
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_location",
|
||||
type: "location-switched",
|
||||
location: { directory },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
|
||||
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
|
||||
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
|
||||
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
|
||||
|
||||
await expect(label).toHaveText("Moved to")
|
||||
await expect(value).toHaveText(directory)
|
||||
await expect(notice).not.toContainText("·")
|
||||
await expect(notice.locator("svg")).toHaveCount(0)
|
||||
await expect(notice).toHaveCSS("height", "28px")
|
||||
await expect(notice).toHaveCSS("gap", "8px")
|
||||
await expect(notice).toHaveCSS("padding-top", "4px")
|
||||
await expect(notice).toHaveCSS("padding-bottom", "4px")
|
||||
await expect(label).toHaveCSS("font-size", "13px")
|
||||
await expect(label).toHaveCSS("font-weight", "530")
|
||||
await expect(label).toHaveCSS("line-height", "16px")
|
||||
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("font-size", "13px")
|
||||
await expect(value).toHaveCSS("font-weight", "440")
|
||||
await expect(value).toHaveCSS("line-height", "16px")
|
||||
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(value).toHaveCSS("white-space", "nowrap")
|
||||
await expect(value).toHaveAttribute("dir", "ltr")
|
||||
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
|
||||
const tooltip = page.getByText("Session working directory changed", { exact: true })
|
||||
await label.hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(tooltip).toBeHidden()
|
||||
await tooltipTrigger.focus()
|
||||
await expect(tooltipTrigger).toBeFocused()
|
||||
await expect(tooltip).toBeVisible()
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
|
||||
const card = page.locator('[data-component="task-tool-card"]')
|
||||
@@ -244,24 +81,7 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await expect(card).not.toContainText("(background)")
|
||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
|
||||
await expect(hint).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [cardBox, hintBox, prefixBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
hint.boundingBox(),
|
||||
hintPrefix.boundingBox(),
|
||||
])
|
||||
if (!cardBox || !hintBox || !prefixBox) return undefined
|
||||
return {
|
||||
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
|
||||
ordered: cardBox.y < hintBox.y,
|
||||
}
|
||||
})
|
||||
.toEqual({ aligned: true, ordered: true })
|
||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
|
||||
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
@@ -284,10 +104,10 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
|
||||
await page.locator('[data-component="task-tool-card"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("shows a badge for active background work", async ({ page }) => {
|
||||
@@ -298,14 +118,7 @@ test("shows a badge for active background work", async ({ page }) => {
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "1 item running in background" })
|
||||
await expect(summary).toContainText("1")
|
||||
await expect(summary).toContainText("Running work in background")
|
||||
await summary.click()
|
||||
await expect(
|
||||
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
|
||||
})
|
||||
|
||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
||||
@@ -380,15 +193,10 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
},
|
||||
})
|
||||
|
||||
const dock = page.locator('[data-component="session-background-dock"]')
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
await expect(summary).toContainText("2")
|
||||
await summary.click()
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
await expect(list).toContainText("Background task")
|
||||
await expect(list).toContainText("sleep 120")
|
||||
await expect(dock).toContainText("Move 1 subagent to background")
|
||||
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
status,
|
||||
toolPart,
|
||||
@@ -70,126 +69,9 @@ test.describe("session timeline projection", () => {
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
|
||||
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
|
||||
await expect(patch.getByRole("button")).toHaveCount(1)
|
||||
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
|
||||
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
|
||||
await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible()
|
||||
await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
first,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update src/first.ts" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
|
||||
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
await expect(initialFile).toBeVisible()
|
||||
await initialFile.getByRole("button").click()
|
||||
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await initial.evaluate((element) => {
|
||||
const row = element.closest<HTMLElement>("[data-timeline-key]")
|
||||
if (row) row.dataset.patchRow = "stable"
|
||||
})
|
||||
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(second, "patch", "running", { patchText: "Update more files" }, { metadata: {} })),
|
||||
)
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
|
||||
await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable")
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
|
||||
await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
second,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update more files" },
|
||||
{
|
||||
metadata: {
|
||||
files: [patchFile("src/first.ts", "modified"), patchFile("src/second.ts", "added")],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
|
||||
await expect(group.locator('[data-scope="apply-patch"] [data-type="update"] button')).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-scope="apply-patch"] [data-type="add"] button')).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_edit_first"
|
||||
const second = "prt_edit_second"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
first,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/first.ts", oldString: "one", newString: "two" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
toolPart(
|
||||
second,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/first.ts", oldString: "two", newString: "three" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
|
||||
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
|
||||
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
@@ -314,7 +196,11 @@ function patchPart(id: string) {
|
||||
{ patchText: "Update the projected files" },
|
||||
{
|
||||
metadata: {
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
files: [
|
||||
patchFile("src/a.ts", "modified"),
|
||||
patchFile("src/b.ts", "added"),
|
||||
patchFile("src/old.ts", "deleted"),
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -98,9 +98,9 @@ test("labels completed searches with result counts", async ({ page }) => {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
|
||||
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
|
||||
const rows = group.locator('[data-component="tool-trigger"]')
|
||||
await expect(rows.nth(0)).toContainText("(1 match)")
|
||||
await expect(rows.nth(1)).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
@@ -111,11 +111,7 @@ test("labels read tools from their path input", async ({ page }) => {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(
|
||||
group
|
||||
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
.filter({ hasText: "Read" }),
|
||||
).toContainText("a.ts")
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
@@ -125,23 +121,25 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "frontend-design" }),
|
||||
toolPart(pending, "skill", "running", { id: "sample-skill" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
for (const [id, name] of [
|
||||
[pending, "frontend-design"],
|
||||
[completed, "OpenCode"],
|
||||
] as const) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"sample-skill",
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"OpenCode",
|
||||
)
|
||||
for (const id of [pending, completed]) {
|
||||
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const loaded = skill.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
|
||||
await expect(loaded).toHaveCSS("line-height", "16px")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
|
||||
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
|
||||
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
|
||||
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
|
||||
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,10 @@ const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const ptyInput: string[] = []
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
ptyInput.length = 0
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -72,22 +70,7 @@ test.beforeEach(async ({ page }) => {
|
||||
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||
ws.onMessage((message) => ptyInput.push(message.toString()))
|
||||
})
|
||||
})
|
||||
|
||||
test("clears the terminal line with Command+Delete", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
|
||||
await page.keyboard.press("Meta+Backspace")
|
||||
|
||||
await expect.poll(() => ptyInput.join("")).toBe("\x15")
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -15,14 +15,3 @@
|
||||
|
||||
/*.css
|
||||
Content-Type: text/css
|
||||
|
||||
/site.webmanifest
|
||||
Content-Type: application/manifest+json
|
||||
|
||||
/sw.js
|
||||
Content-Type: application/javascript
|
||||
Cache-Control: no-cache
|
||||
|
||||
/registerSW.js
|
||||
Content-Type: application/javascript
|
||||
Cache-Control: no-cache
|
||||
|
||||
@@ -51,7 +51,6 @@ export type ComposerSession = {
|
||||
location: { command: Pick<Data["location"]["command"], "list"> }
|
||||
session: {
|
||||
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
|
||||
setStatus: Data["session"]["setStatus"]
|
||||
}
|
||||
}
|
||||
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
@@ -78,7 +77,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
|
||||
start: (
|
||||
selection: ComposerSelection,
|
||||
submission: ReturnType<typeof createComposerSubmission>,
|
||||
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
|
||||
) => Promise<ComposerSession | undefined>
|
||||
}
|
||||
|
||||
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Composer(props: {
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
variantControlVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
modelControl={
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ComposerEditorProps = {
|
||||
borderUnderlay?: boolean
|
||||
class?: string
|
||||
modelControl?: JSX.Element
|
||||
modelControlsVisible?: boolean
|
||||
variantControlVisible?: boolean
|
||||
attachKeybind?: string[]
|
||||
attachShortcut?: string
|
||||
}
|
||||
@@ -233,19 +233,17 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
{props.modelControl}
|
||||
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<ComposerEditorSubmitButton
|
||||
|
||||
@@ -69,7 +69,6 @@ function submitInput(
|
||||
function session(input: {
|
||||
calls: string[]
|
||||
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
|
||||
statuses?: ("idle" | "running")[]
|
||||
current?: ComposerSession["current"]
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
@@ -93,7 +92,6 @@ function session(input: {
|
||||
data: {
|
||||
location: { command: { list: () => [] } },
|
||||
session: {
|
||||
setStatus: (_sessionID, status) => input.statuses?.push(status),
|
||||
prompt: async (value) => {
|
||||
input.calls.push("prompt")
|
||||
await input.prompt(value)
|
||||
@@ -142,12 +140,10 @@ describe("Composer submission", () => {
|
||||
|
||||
test("starts and promotes a New Session once before admitting its first prompt", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
|
||||
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const cleanupReady = Promise.withResolvers<void>()
|
||||
const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) })
|
||||
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
@@ -160,20 +156,14 @@ describe("Composer submission", () => {
|
||||
async start(_selection, submission) {
|
||||
calls.push("start")
|
||||
submission.retarget(promoted)
|
||||
return { session: target, cleanupReady: cleanupReady.promise }
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
const submitted = submitInput(adapter).submit(new Event("submit"))
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"])
|
||||
expect(statuses).toEqual(["running"])
|
||||
expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }])
|
||||
cleanupReady.resolve()
|
||||
await submitted
|
||||
|
||||
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"])
|
||||
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("first prompt")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
@@ -243,7 +233,7 @@ describe("Composer submission", () => {
|
||||
submitted() {},
|
||||
async start(_selection, submission) {
|
||||
submission.retarget(promoted)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
@@ -260,12 +250,10 @@ describe("Composer submission", () => {
|
||||
test("reuses the message ID when an unacknowledged admission is retried", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
|
||||
const attempts: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
throw new Error("network unavailable")
|
||||
@@ -295,7 +283,6 @@ describe("Composer submission", () => {
|
||||
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
|
||||
})
|
||||
|
||||
|
||||
@@ -65,42 +65,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const comments = input.comments.capture()
|
||||
|
||||
try {
|
||||
const started =
|
||||
const session =
|
||||
input.adapter.kind === "active-session"
|
||||
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
|
||||
? input.adapter.session()
|
||||
: await input.adapter.start(value.selection, submission)
|
||||
if (!started) return
|
||||
const session = started.session
|
||||
if (!session) return
|
||||
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
await started.cleanupReady
|
||||
input.adapter.submitted()
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sending.then((result) => {
|
||||
if (!result.ok)
|
||||
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await started.cleanupReady
|
||||
input.adapter.submitted()
|
||||
|
||||
if (value.mode === "shell") {
|
||||
@@ -109,6 +82,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const command = findCommand(session, value.text)
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command).catch((error) =>
|
||||
@@ -117,6 +91,14 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sendPrompt(session, value).catch((error) =>
|
||||
failSubmission(input, session, "prompt", error, restore, value.id),
|
||||
)
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
@@ -331,10 +313,8 @@ function failSubmission(
|
||||
error: unknown,
|
||||
restore: () => boolean,
|
||||
messageID?: string,
|
||||
rollback?: () => void,
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
rollback?.()
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
}
|
||||
|
||||
@@ -42,10 +42,6 @@ const clearAuthToken = () => {
|
||||
|
||||
const web = createWebPlatform(pkg.version)
|
||||
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true })
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
@@ -65,9 +61,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
})
|
||||
}
|
||||
|
||||
if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
// Lazy chunks can import the entry chunk back under a distinct URL, so claim the root before async startup.
|
||||
root.dataset.opencodeMounted = ""
|
||||
if (root instanceof HTMLElement) {
|
||||
void loadInitialLocale().then((locale) => {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
|
||||
import { createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
|
||||
export function createHomeController() {
|
||||
const layout = useLayout()
|
||||
@@ -50,8 +50,7 @@ export function createHomeController() {
|
||||
selection: {
|
||||
value: selection,
|
||||
set: setSelection,
|
||||
focusServer: (conn: ServerConnection.Any) =>
|
||||
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
|
||||
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
|
||||
},
|
||||
server: {
|
||||
list: global.servers.list,
|
||||
|
||||
@@ -509,7 +509,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div
|
||||
class={`
|
||||
shrink-0 text-[13px] leading-text-compact tracking-[-0.04px]
|
||||
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
|
||||
text-v2-text-text-base [font-weight:530]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -53,32 +53,26 @@ export function createNewSessionComposerAdapter(props: {
|
||||
})
|
||||
if (!sessionDirectory) return
|
||||
|
||||
const created = data.session.create({
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
providerID: selection.model.providerID,
|
||||
variant: selection.variant,
|
||||
},
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
const creation = created.request.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => {
|
||||
const created = await serverSDK.api.session
|
||||
.create({
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
providerID: selection.model.providerID,
|
||||
variant: selection.variant,
|
||||
},
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(language, error),
|
||||
})
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
const afterCreation = async <T,>(run: () => Promise<T>) => {
|
||||
const result = await creation
|
||||
if (!result.ok) throw result.error
|
||||
return run()
|
||||
}
|
||||
})
|
||||
if (!created) return
|
||||
|
||||
const cleanupReady = startTransition(() => {
|
||||
data.session.remember(created)
|
||||
await startTransition(() => {
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined })
|
||||
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
|
||||
permission.enableAutoAccept(created.id, sessionDirectory)
|
||||
@@ -98,31 +92,13 @@ export function createNewSessionComposerAdapter(props: {
|
||||
})
|
||||
|
||||
return {
|
||||
cleanupReady,
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
api: {
|
||||
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
|
||||
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
|
||||
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
|
||||
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
|
||||
},
|
||||
data: {
|
||||
location: data.location,
|
||||
session: {
|
||||
setStatus: data.session.setStatus,
|
||||
prompt: (input) =>
|
||||
data.session.prompt({
|
||||
...input,
|
||||
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
current: () => data.session.get(created.id),
|
||||
admitted: (messageID) =>
|
||||
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
|
||||
},
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
api: serverSDK.api.session,
|
||||
data,
|
||||
current: () => data.session.get(created.id) ?? created,
|
||||
admitted: (messageID) =>
|
||||
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPromptProjectController } from "@/new-session/project/selector"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
@@ -14,6 +15,7 @@ import { useNewSessionCommands } from "./commands"
|
||||
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
|
||||
export default function NewSessionPage(props: { draftId: string }) {
|
||||
const settings = useSettings()
|
||||
const rightMount = useTitlebarRightMount()
|
||||
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const tabs = useTabs()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
@@ -67,7 +69,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Wordmark } from "@opencode-ai/ui/wordmark"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
type PromptProjectController,
|
||||
} from "@/new-session/project/selector"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -87,16 +87,21 @@ export function NewSessionView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function NewSessionStatus(props: { visible: boolean }) {
|
||||
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<TitlebarRight>
|
||||
<Show when={props.visible}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<Show when={props.mount} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<Show when={props.visible}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,7 +144,7 @@ function ProviderTip() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
|
||||
@@ -190,7 +190,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
{(group) => (
|
||||
<Show when={group.items().length > 0}>
|
||||
<section class="flex flex-col">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{group.title}
|
||||
</div>
|
||||
<For each={group.items()}>
|
||||
@@ -198,7 +198,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={provider.id}
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-text-compact tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
||||
onMouseEnter={() => setStore("active", provider.id)}
|
||||
disabled={store.connecting !== undefined}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
|
||||
type Store = {
|
||||
user: User[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
}
|
||||
|
||||
const createModelsPersistedState = () => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model"),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
return [store, setStore, ready] as const
|
||||
}
|
||||
|
||||
const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const providers = useProviders(() => directory())
|
||||
const models = useGlobal().models
|
||||
const store = models.store
|
||||
const setStore = models.set
|
||||
|
||||
const [store, setStore, ready] = createModelsPersistedState()
|
||||
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
@@ -128,14 +148,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
const [recentModels] = createResource(
|
||||
async () => {
|
||||
const recent = store.recent
|
||||
await ready.promise
|
||||
return recent
|
||||
},
|
||||
(p) => p,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
return {
|
||||
ready: models.ready,
|
||||
ready,
|
||||
list,
|
||||
find,
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: models.recent,
|
||||
list: () => recentModels()!,
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
|
||||
@@ -30,7 +30,6 @@ export const dict = {
|
||||
"command.project.previous": "Previous project",
|
||||
"command.project.next": "Next project",
|
||||
"command.project.index": "Switch to project {{index}}",
|
||||
"command.project.copyID": "Copy Project ID",
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
@@ -94,7 +93,6 @@ export const dict = {
|
||||
"command.session.fork.description": "Create a new session from a previous message",
|
||||
"command.session.export": "Export session",
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
"command.session.copyID": "Copy Session ID",
|
||||
|
||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||
"palette.search.placeholder.home": "Search commands and sessions",
|
||||
@@ -546,6 +544,7 @@ export const dict = {
|
||||
"toast.context.noLineSelection.title": "No line selection",
|
||||
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
|
||||
|
||||
|
||||
"toast.session.unshare.success.title": "Session unshared",
|
||||
"toast.session.unshare.success.description": "Session unshared successfully!",
|
||||
"toast.session.unshare.failed.title": "Failed to unshare session",
|
||||
@@ -555,10 +554,6 @@ export const dict = {
|
||||
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||
"toast.session.export.failed.title": "Failed to export session",
|
||||
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||
"toast.session.copyID.failed.title": "Failed to copy session ID",
|
||||
"toast.session.copyID.failed.description": "An error occurred while copying the session ID",
|
||||
"toast.project.copyID.failed.title": "Failed to copy project ID",
|
||||
"toast.project.copyID.failed.description": "An error occurred while copying the project ID",
|
||||
|
||||
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||
@@ -570,13 +565,10 @@ export const dict = {
|
||||
"toast.update.action.notYet": "Not yet",
|
||||
|
||||
"error.page.title": "Something went wrong",
|
||||
"error.page.title.status": "Server request failed",
|
||||
"error.page.description": "An error occurred while loading the application.",
|
||||
"error.page.description.status": "The server returned an HTTP {{status}} response.",
|
||||
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
|
||||
"error.page.details.label": "Error Details",
|
||||
"error.page.action.restart": "Restart",
|
||||
"error.page.action.reload": "Reload",
|
||||
"error.page.action.report": "Report Error",
|
||||
"error.page.action.reported": "Error Reported",
|
||||
"error.page.action.exportLogs": "Export Logs",
|
||||
@@ -665,10 +657,6 @@ export const dict = {
|
||||
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
|
||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
||||
"session.background.inBackground": "Running {{tasks}} in background",
|
||||
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
|
||||
"session.background.running": "Running work in background",
|
||||
"session.background.runningCount.one": "{{count}} item running in background",
|
||||
"session.background.runningCount.other": "{{count}} items running in background",
|
||||
"session.background.combine": "{{first}} and {{second}}",
|
||||
"session.background.shell.one": "{{count}} shell",
|
||||
"session.background.shell.other": "{{count}} shells",
|
||||
@@ -925,10 +913,6 @@ export const dict = {
|
||||
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
|
||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||
"settings.general.row.terminalPlacement.title": "Terminal placement",
|
||||
"settings.general.row.terminalPlacement.description": "Choose where the terminal opens in sessions",
|
||||
"settings.general.row.terminalPlacement.side": "Side",
|
||||
"settings.general.row.terminalPlacement.bottom": "Bottom",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
|
||||
"settings.general.row.colorScheme.title": "Color scheme",
|
||||
@@ -951,8 +935,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "Project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { Accessor, createEffect, createMemo, createRoot, getOwner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
@@ -10,7 +10,6 @@ import { createData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createServerPermissionState } from "@/session/requests/server-permission"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -25,7 +24,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
serverKey: undefined as ServerConnection.Key | undefined,
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -88,7 +86,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
},
|
||||
models,
|
||||
ensureServerCtx(conn: ServerConnection.Any) {
|
||||
return ensureServerCtx(conn)
|
||||
},
|
||||
@@ -96,37 +93,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
|
||||
function createGlobalModels() {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model"),
|
||||
createStore<{
|
||||
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
|
||||
recent: Array<{ providerID: string; modelID: string }>
|
||||
variant?: Record<string, string | undefined>
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
const [recent] = createResource(
|
||||
async () => {
|
||||
const value = store.recent
|
||||
await ready.promise
|
||||
return value
|
||||
},
|
||||
(value) => value,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
return {
|
||||
store,
|
||||
set: setStore,
|
||||
ready,
|
||||
recent: () => recent()!,
|
||||
}
|
||||
}
|
||||
|
||||
function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
padding: 48px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const focusInput = actions.focusInput
|
||||
|
||||
const sessionCommand = withCategory(language.t("command.category.session"))
|
||||
const projectCommand = withCategory(language.t("command.category.project"))
|
||||
const fileCommand = withCategory(language.t("command.category.file"))
|
||||
const contextCommand = withCategory(language.t("command.category.context"))
|
||||
const viewCommand = withCategory(language.t("command.category.view"))
|
||||
@@ -127,46 +126,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
}
|
||||
|
||||
const copySessionID = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionID)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("common.copied"),
|
||||
description: sessionID,
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.copyID.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.copyID.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const copyProjectID = async () => {
|
||||
const projectID = actions.session.data.info()?.projectID
|
||||
if (!projectID) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(projectID)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("common.copied"),
|
||||
description: projectID,
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.copyID.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.project.copyID.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void openDialog(
|
||||
() => import("@/shell/commands/dialog"),
|
||||
@@ -312,12 +271,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
disabled: !actions.session.identity.params.id,
|
||||
onSelect: exportSession,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.copyID",
|
||||
title: language.t("command.session.copyID"),
|
||||
disabled: !actions.session.identity.params.id,
|
||||
onSelect: copySessionID,
|
||||
}),
|
||||
]
|
||||
|
||||
const fileCmds = () => {
|
||||
@@ -341,15 +294,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
].filter((v) => !!v)
|
||||
}
|
||||
|
||||
const projectCmds = () => [
|
||||
projectCommand({
|
||||
id: "project.copyID",
|
||||
title: language.t("command.project.copyID"),
|
||||
disabled: !actions.session.data.info()?.projectID,
|
||||
onSelect: copyProjectID,
|
||||
}),
|
||||
]
|
||||
|
||||
const contextCmds = () => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
@@ -463,7 +407,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
|
||||
command.register("session", () => [
|
||||
...sessionCmds(),
|
||||
...projectCmds(),
|
||||
...fileCmds(),
|
||||
...contextCmds(),
|
||||
...viewCmds(),
|
||||
|
||||
@@ -16,7 +16,6 @@ export function createActiveComposerAdapter(input: {
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
|
||||
const prompt = useComposerState()
|
||||
prompt.current()
|
||||
const state = prompt.capture()
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
|
||||
@@ -133,7 +133,7 @@ export function createActiveSessionRegion(input: {
|
||||
const scroller = input.timeline.scroller()
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
|
||||
input.timeline.view.markUserScroll(scroller)
|
||||
input.timeline.view.markGesture(scroller)
|
||||
return
|
||||
}
|
||||
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
|
||||
@@ -204,7 +204,9 @@ export function ActiveSessionComposerRegion(props: {
|
||||
}) {
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
sessionKey: props.session.identity.sessionKey,
|
||||
sessionID: () => props.session.identity.params.id,
|
||||
prompt: props.model.region.prompt,
|
||||
centered: props.model.region.centered,
|
||||
onResponseSubmit: props.onResponseSubmit,
|
||||
openParent: props.model.region.openParent,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
|
||||
import type { useComposerState } from "@/composer/persistence"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/session/handoff"
|
||||
import type { SessionRequestModel } from "../requests/model"
|
||||
|
||||
export function createSessionComposerRegionController(input: {
|
||||
state: SessionRequestModel
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
prompt: ReturnType<typeof useComposerState>
|
||||
centered: Accessor<boolean>
|
||||
onResponseSubmit: () => void
|
||||
openParent: () => void
|
||||
@@ -12,10 +16,32 @@ export function createSessionComposerRegionController(input: {
|
||||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
createEffect(() => {
|
||||
if (!input.prompt.ready()) return
|
||||
setSessionHandoff(input.sessionKey(), {
|
||||
prompt: input.prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? data.session.get(id)?.parentID : undefined
|
||||
})
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => input.prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return {
|
||||
state: input.state,
|
||||
centered: input.centered,
|
||||
@@ -26,6 +52,8 @@ export function createSessionComposerRegionController(input: {
|
||||
parentID,
|
||||
child: () => !!parentID(),
|
||||
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||
promptReady: () => input.prompt.ready() || promptReady(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
|
||||
import { SessionBackgroundDock } from "@/session/requests/session-background-dock"
|
||||
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
type SessionComposerRegionState = Pick<
|
||||
SessionComposerRegionController["state"],
|
||||
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
|
||||
>
|
||||
> & {
|
||||
background: Pick<SessionComposerRegionController["state"]["background"], "blocking" | "tasks" | "move">
|
||||
}
|
||||
|
||||
export type SessionComposerRegionViewController = Pick<
|
||||
SessionComposerRegionController,
|
||||
@@ -19,6 +22,8 @@ export type SessionComposerRegionViewController = Pick<
|
||||
| "parentID"
|
||||
| "child"
|
||||
| "showComposer"
|
||||
| "handoffPrompt"
|
||||
| "promptReady"
|
||||
> & { state: SessionComposerRegionState }
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
@@ -27,6 +32,9 @@ export function SessionComposerRegion(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const controller = props.controller
|
||||
const background = () =>
|
||||
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={controller.setDockRef}
|
||||
@@ -63,32 +71,55 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={controller.showComposer()}>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
<Show
|
||||
when={controller.promptReady()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={controller.child()}
|
||||
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
|
||||
>
|
||||
<div
|
||||
ref={controller.setPromptRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
<Show when={controller.parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-base transition-colors hover:text-text-strong"
|
||||
onClick={controller.openParent}
|
||||
>
|
||||
{language.t("session.child.backToParent")}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={background()}>
|
||||
<div>
|
||||
<SessionBackgroundDock
|
||||
blocking={controller.state.background.blocking()}
|
||||
tasks={controller.state.background.tasks()}
|
||||
onBackground={() => void controller.state.background.move()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${background() ? -36 : 0}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={controller.child()}
|
||||
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
|
||||
>
|
||||
<div
|
||||
ref={controller.setPromptRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
<Show when={controller.parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-base transition-colors hover:text-text-strong"
|
||||
onClick={controller.openParent}
|
||||
>
|
||||
{language.t("session.child.backToParent")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
|
||||
type HandoffSession = {
|
||||
prompt: string
|
||||
files: Record<string, SelectedLineRange | null>
|
||||
}
|
||||
|
||||
@@ -22,7 +23,7 @@ const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||
}
|
||||
|
||||
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
||||
const prev = store.session.get(key) ?? { files: {} }
|
||||
const prev = store.session.get(key) ?? { prompt: "", files: {} }
|
||||
touch(store.session, key, { ...prev, ...patch })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
|
||||
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
|
||||
|
||||
export function SessionHeader() {
|
||||
@@ -27,9 +28,15 @@ export function SessionHeader() {
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
return (
|
||||
<TitlebarRight>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
</TitlebarRight>
|
||||
<Show when={rightMount()} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<SessionHeaderActions state={actions()} />
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export function createSessionRequestModel() {
|
||||
return [
|
||||
{
|
||||
type: part.name as "shell" | "subagent",
|
||||
partID: part.id,
|
||||
id: typeof value === "string" ? value : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
},
|
||||
@@ -94,13 +93,11 @@ export function createSessionRequestModel() {
|
||||
const sessionID = part.state.metadata.sessionID
|
||||
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
|
||||
const description = part.state.input.description
|
||||
const agent = part.state.input.agent
|
||||
return [
|
||||
{
|
||||
id: sessionID,
|
||||
type: "subagent" as const,
|
||||
label: typeof description === "string" ? description : sessionID,
|
||||
agent: typeof agent === "string" ? agent : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { For, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { SessionBackgroundPullout } from "./session-background-pullout"
|
||||
|
||||
export function SessionBackgroundDock(props: {
|
||||
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
|
||||
tasks: { id: string; type: "shell" | "subagent"; label: string }[]
|
||||
onBackground: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const [store, setStore] = createStore({ collapsed: true })
|
||||
const describe = (shells: number, subagents: number) => {
|
||||
const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined
|
||||
const subagent = subagents
|
||||
? language.plural("session.background.subagent", subagents, { count: subagents })
|
||||
: undefined
|
||||
if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent })
|
||||
return shell ?? subagent ?? ""
|
||||
}
|
||||
const summary = createMemo(() => {
|
||||
const shells = props.tasks.filter((task) => task.type === "shell").length
|
||||
return describe(shells, props.tasks.length - shells)
|
||||
})
|
||||
const moving = createMemo(() => {
|
||||
const shells = props.blocking.filter((task) => task.type === "shell").length
|
||||
const subagents = props.blocking.length - shells
|
||||
const tasks = describe(shells, subagents)
|
||||
return tasks ? language.t("session.background.moveTasks", { tasks }) : ""
|
||||
})
|
||||
const background = createMemo(() =>
|
||||
summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "",
|
||||
)
|
||||
const blocking = () => props.blocking.length > 0
|
||||
const toggle = () => {
|
||||
if (blocking()) {
|
||||
props.onBackground()
|
||||
return
|
||||
}
|
||||
setStore("collapsed", (value) => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionBackgroundPullout
|
||||
label={
|
||||
<span class="flex flex-col items-start">
|
||||
{blocking() && (
|
||||
<span>
|
||||
<span class="text-v2-text-text-muted">{moving()}</span>
|
||||
<span class="pl-2">
|
||||
<Keybind keys={command.keybindParts("session.background")} variant="neutral" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{!!props.tasks.length && <span class="text-v2-text-text-faint">{background()}</span>}
|
||||
</span>
|
||||
}
|
||||
ariaLabel={[moving(), background()].filter(Boolean).join(". ")}
|
||||
multiline={blocking() && props.tasks.length > 0}
|
||||
collapsed={blocking() || store.collapsed}
|
||||
collapsible={!blocking()}
|
||||
onToggle={toggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
>
|
||||
<div class="px-4 pb-11 flex flex-col gap-1.5">
|
||||
<For each={props.tasks}>
|
||||
{(task) => (
|
||||
<div class="flex min-w-0 items-baseline gap-2 text-13-regular">
|
||||
<span class="shrink-0 text-13-medium text-text-strong">
|
||||
{language.t(task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default")}
|
||||
</span>
|
||||
<span class="truncate text-text-weak">{task.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SessionBackgroundPullout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, createMemo, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function SessionBackgroundPullout(props: {
|
||||
label: JSX.Element
|
||||
ariaLabel: string
|
||||
multiline?: boolean
|
||||
collapsed: boolean
|
||||
collapsible?: boolean
|
||||
onToggle: () => void
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const [store, setStore] = createStore({ height: 78, header: 42 })
|
||||
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const off = createMemo(() => value() > 0.98)
|
||||
const base = createMemo(() => Math.max(78, store.header + 36))
|
||||
const full = createMemo(() => Math.max(base(), store.height))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const element = contentRef
|
||||
const header = headerRef
|
||||
if (!element || !header) return
|
||||
const update = () => {
|
||||
setStore("height", (height) => Math.max(height, element.scrollHeight))
|
||||
setStore("header", header.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
createResizeObserver([element, header], update)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="session-background-dock"
|
||||
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(base(), full() - value() * (full() - base()))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
ref={headerRef}
|
||||
data-action="session-background-toggle"
|
||||
class="flex items-center gap-2 overflow-visible pl-4 pr-2"
|
||||
classList={{
|
||||
"h-[42px]": !props.multiline,
|
||||
"min-h-[42px] py-2": props.multiline,
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={props.onToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
props.onToggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="cursor-default inline-flex items-baseline shrink-0 overflow-visible font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
aria-label={props.ariaLabel}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": "600ms",
|
||||
"--tool-motion-mask": "18%",
|
||||
"--tool-motion-mask-height": "0px",
|
||||
"--tool-motion-spring-ms": "560ms",
|
||||
"white-space": "pre",
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
{props.collapsible !== false && (
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-background-toggle-button"
|
||||
data-collapsed={props.collapsed ? "true" : "false"}
|
||||
icon={<Icon name="chevron-down" />}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${value() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-background-list"
|
||||
aria-hidden={props.collapsed || off()}
|
||||
classList={{ "pointer-events-none": value() > 0.1 }}
|
||||
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - value())}` }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { useSessionModel } from "./model"
|
||||
import { SessionPanelFrame } from "./session-frame"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "./session-frame"
|
||||
import { IncompatibleServerPanel } from "./incompatible-server-panel"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionResolution } from "./session-resolution"
|
||||
@@ -32,7 +31,7 @@ export function TargetSessionRouteContent() {
|
||||
<MarkSessionNotificationsViewed sessionID={() => params.id} />
|
||||
<ModelsProvider directory={directory}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</ModelsProvider>
|
||||
@@ -46,14 +45,16 @@ function TargetSessionSettingsCommand() {
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<SessionStatePanel>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionStatePanel>
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -77,14 +78,16 @@ function ResolvedTargetSessionRoute() {
|
||||
<Show
|
||||
when={!server.health?.incompatible}
|
||||
fallback={
|
||||
<SessionStatePanel>
|
||||
<IncompatibleServerPanel
|
||||
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
|
||||
/>
|
||||
</SessionStatePanel>
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised>
|
||||
<IncompatibleServerPanel
|
||||
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
|
||||
/>
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
}
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
<Show when={directory()}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value()}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
@@ -97,22 +100,6 @@ function ResolvedTargetSessionRoute() {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingSessionState(props: { sessionID: string }) {
|
||||
return (
|
||||
<SessionStatePanel>
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
</SessionStatePanel>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionStatePanel(props: ParentProps) {
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 p-2">
|
||||
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
|
||||
@@ -15,10 +15,6 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
|
||||
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
|
||||
const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen())
|
||||
const sideTerminalOpen = createMemo(() => desktopTerminalOpen() && settings.general.terminalPlacement() === "side")
|
||||
const bottomTerminalOpen = createMemo(
|
||||
() => desktopTerminalOpen() && settings.general.terminalPlacement() === "bottom",
|
||||
)
|
||||
const fileTreeOpen = createMemo(
|
||||
() =>
|
||||
session.isDesktop() &&
|
||||
@@ -27,7 +23,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
|
||||
const resizable = createMemo(() => reviewPanelOpen() || desktopTerminalOpen())
|
||||
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
|
||||
const [rowWidth, setRowWidth] = createSignal<number>()
|
||||
let row: HTMLDivElement | undefined
|
||||
@@ -61,7 +57,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
const panelLayout = createMemo(() =>
|
||||
sessionPanelLayout({
|
||||
review: reviewPanelOpen(),
|
||||
terminal: sideTerminalOpen(),
|
||||
terminal: desktopTerminalOpen(),
|
||||
files: fileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
@@ -101,11 +97,11 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
panelOpen: reviewPanelOpen,
|
||||
snap: reviewSnap,
|
||||
},
|
||||
side: { layout: panelLayout },
|
||||
side: { layout: panelLayout, open: sidePanelOpen },
|
||||
size,
|
||||
terminal: {
|
||||
bottomOpen: bottomTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
|
||||
desktopOpen: desktopTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => desktopTerminalOpen() && !reviewPanelOpen()),
|
||||
open: terminalOpen,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { TerminalPanel } from "@/session/terminal/panel"
|
||||
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
@@ -17,7 +17,6 @@ import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
@@ -59,52 +58,39 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
{timeline.resource() ?? ""}
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
|
||||
<SessionMobileTabs review={review} compact />
|
||||
</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
{(error) => {
|
||||
throw error()
|
||||
}}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={session.identity.params.id && review.mobile.changes()}>
|
||||
<SessionMobileReview review={review} />
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={!messagesReady()}>
|
||||
<SessionIdentityHeader
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
session={session.data.info()}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={messagesReady() ? session.identity.params.id : undefined}
|
||||
keyed
|
||||
>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
session={session}
|
||||
background={composer.region.state.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
setScrollRef={timeline.view.setScrollRef}
|
||||
onScheduleScrollState={timeline.view.scheduleScrollState}
|
||||
onPin={timeline.view.pin}
|
||||
onUnpin={timeline.view.unpin}
|
||||
onAutoScrollHandleScroll={timeline.autoScroll.handleScroll}
|
||||
onMarkScrollGesture={timeline.view.markGesture}
|
||||
hasScrollGesture={timeline.view.hasGesture()}
|
||||
onUserScroll={timeline.view.markUserScroll}
|
||||
onHistoryScroll={timeline.view.onHistoryScroll}
|
||||
onSelectionInteraction={timeline.view.selectionInteraction}
|
||||
pinned={timeline.view.pinned()}
|
||||
onAutoScrollInteraction={timeline.autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={timeline.view.shouldAnchorBottom()}
|
||||
centered={screen.centered()}
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
diffs={review.details.diffs}
|
||||
onReview={review.open}
|
||||
workspaceMoveEligible={composer.workspaceMoveEligible()}
|
||||
onSummaryOpenChange={review.details.setOpen}
|
||||
setHistoryAnchor={timeline.view.setHistoryAnchor}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
@@ -132,90 +118,53 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionRouteFrame>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
|
||||
}}
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
>
|
||||
<Show when={screen.panel.key()} keyed>
|
||||
{(_) => (
|
||||
<SessionPanelFrame raised={!!session.identity.params.id}>
|
||||
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
</Show>
|
||||
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2 p-2">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
|
||||
}}
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
>
|
||||
<Show when={screen.panel.key()} keyed>
|
||||
{(_) => (
|
||||
<SessionPanelFrame raised={!!session.identity.params.id}>
|
||||
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={screen.panel.resizable()}>
|
||||
<div onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="-end-1"
|
||||
direction="horizontal"
|
||||
size={screen.panel.resizedWidth()}
|
||||
min={SESSION_PANEL_WIDTH_MIN}
|
||||
max={screen.panel.max()}
|
||||
onResize={(width) => {
|
||||
screen.size.touch()
|
||||
layout.session.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isDesktop() && screen.side.layout().visible}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={screen.review.panelOpen() || screen.files.open()}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionDesktopReview review={review} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.terminal.open() && !screen.terminal.bottomOpen()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-h-0 shrink-0": screen.side.layout().stacked,
|
||||
"min-h-0 flex-1": !screen.side.layout().stacked,
|
||||
}}
|
||||
>
|
||||
<TerminalPanel stacked={screen.side.layout().stacked} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.panel.resizable()}>
|
||||
<div onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="-end-1"
|
||||
direction="horizontal"
|
||||
size={screen.panel.resizedWidth()}
|
||||
min={SESSION_PANEL_WIDTH_MIN}
|
||||
max={screen.panel.max()}
|
||||
onResize={(width) => {
|
||||
screen.size.touch()
|
||||
layout.session.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={screen.terminal.open() && (!isDesktop() || screen.terminal.bottomOpen())}>
|
||||
<div classList={{ "relative min-h-0 shrink-0": isDesktop() }}>
|
||||
<Show when={isDesktop()}>
|
||||
<div
|
||||
class="absolute z-10 -top-1 left-0 right-0 h-2"
|
||||
onPointerDown={() => screen.size.start()}
|
||||
>
|
||||
<Show when={isDesktop() ? screen.side.layout().visible : screen.terminal.open()}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={isDesktop() && (screen.review.panelOpen() || screen.files.open())}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionDesktopReview review={review} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
@@ -231,10 +180,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel stacked={isDesktop()} />
|
||||
<Show when={screen.terminal.open()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-h-0 shrink-0": screen.side.layout().stacked,
|
||||
"min-h-0 flex-1": !screen.side.layout().stacked,
|
||||
}}
|
||||
>
|
||||
<TerminalPanel stacked={screen.side.layout().stacked} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionTitle } from "./title"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
<div
|
||||
data-session-title
|
||||
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const info = createMemo(
|
||||
() => tabs.info[tabKey({ type: "session", server: server.key, sessionId: props.sessionID })],
|
||||
)
|
||||
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
|
||||
const title = createMemo(() => sessionTitle(props.session?.title ?? info()?.title))
|
||||
const project = createMemo(() => {
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session) return projectForSession(props.session, projects)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
return projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
)
|
||||
})
|
||||
const showProjectIcon = () =>
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
|
||||
return (
|
||||
<Show when={title() || showProjectIcon()}>
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center">
|
||||
<span
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
|
||||
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={showProjectIcon()}
|
||||
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
|
||||
src={getProjectAvatarSource(project()?.id, project()?.icon)}
|
||||
variant={getProjectAvatarVariant(project()?.icon?.color)}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<h1
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createSessionResolution } from "./session-resolution"
|
||||
|
||||
describe("session resolution", () => {
|
||||
test("waits for a route session ID", () => {
|
||||
createRoot((dispose) => {
|
||||
let syncs = 0
|
||||
const sessions = {
|
||||
get: () => undefined,
|
||||
sync: () => {
|
||||
syncs++
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
const session = createSessionResolution(() => undefined, () => sessions)
|
||||
|
||||
expect(session()).toBeUndefined()
|
||||
expect(syncs).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -29,20 +29,15 @@ type Resolution<T> = { id: string; store: SessionStore<T> } & (
|
||||
// session that simply has not resolved yet. Resolve failures rethrow on read so
|
||||
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
|
||||
export function createSessionResolution<T>(
|
||||
sessionID: () => string | undefined,
|
||||
sessionID: () => string,
|
||||
sessions: () => SessionStore<T>,
|
||||
options?: { children?: boolean },
|
||||
) {
|
||||
const cached = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return sessions().get(id)
|
||||
})
|
||||
const cached = createMemo(() => sessions().get(sessionID()))
|
||||
const [status, setStatus] = createSignal<Resolution<T>>()
|
||||
|
||||
createEffect(
|
||||
on([sessionID, sessions] as const, ([id, store]) => {
|
||||
if (!id) return
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
@@ -65,11 +60,10 @@ export function createSessionResolution<T>(
|
||||
|
||||
return createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
const value = cached()
|
||||
if (value) return value
|
||||
const state = status()
|
||||
if (!state || state.id !== id || state.store !== sessions()) return undefined
|
||||
if (state?.id !== id || state.store !== sessions()) return undefined
|
||||
if (state.state === "failed") throw state.failure
|
||||
// A session missing after settlement was deleted, possibly by another client.
|
||||
// Match the resolve error so the boundary shows the
|
||||
|
||||
@@ -84,6 +84,7 @@ export type SessionPreviewProps = {
|
||||
draft?: string
|
||||
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
|
||||
reviewOpened?: boolean
|
||||
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
|
||||
child?: { parentID: string }
|
||||
terminal?: { title: string; lines: string[] }
|
||||
}
|
||||
@@ -195,6 +196,13 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
setState("request", undefined)
|
||||
setState("activity", `Permission response: ${response}`)
|
||||
},
|
||||
background: {
|
||||
blocking: () => [],
|
||||
tasks: () => props.backgroundTasks ?? [],
|
||||
move: async () => {
|
||||
setState("activity", "Requested background execution")
|
||||
},
|
||||
},
|
||||
blocked: () => state.request !== undefined,
|
||||
},
|
||||
centered: () => true,
|
||||
@@ -208,6 +216,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
parentID: () => props.child?.parentID,
|
||||
child: () => !!props.child,
|
||||
showComposer: () => true,
|
||||
handoffPrompt: () => undefined,
|
||||
promptReady: () => true,
|
||||
} satisfies SessionComposerRegionViewController
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { terminalKeyInput } from "./terminal-key-event"
|
||||
|
||||
describe("terminalKeyInput", () => {
|
||||
test("maps Command+Delete to the terminal line-clear control code", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: "Backspace", metaKey: true })
|
||||
|
||||
expect(terminalKeyInput(event)).toBe("\x15")
|
||||
})
|
||||
|
||||
test("leaves other Backspace shortcuts to the terminal", () => {
|
||||
expect(terminalKeyInput(new KeyboardEvent("keydown", { key: "Backspace" }))).toBeUndefined()
|
||||
expect(
|
||||
terminalKeyInput(new KeyboardEvent("keydown", { key: "Backspace", metaKey: true, shiftKey: true })),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
export function terminalKeyInput(event: KeyboardEvent) {
|
||||
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "backspace") return
|
||||
return "\x15"
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { terminalFontFamily, useSettings } from "@/settings/model"
|
||||
import type { LocalPTY } from "@/session/terminal/context"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/session/terminal/runtime-adapters"
|
||||
import { terminalKeyInput } from "@/session/terminal/terminal-key-event"
|
||||
import { terminalWriter } from "@/session/terminal/writer"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
@@ -409,12 +408,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
const input = terminalKeyInput(event)
|
||||
if (input) {
|
||||
t.input(input, true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
|
||||
document.execCommand("copy")
|
||||
return true
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
|
||||
|
||||
const tasks = [
|
||||
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
|
||||
{ id: "task_status", type: "shell" as const, label: "opencode2 service status" },
|
||||
{ id: "task_openapi", type: "shell" as const, label: "opencode2 api get /openapi.json" },
|
||||
{ id: "task_tests", type: "shell" as const, label: "bun test packages/app" },
|
||||
]
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Session/Background work",
|
||||
id: "session-background-work",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "Production controls for moving blocking work and inspecting active background tasks.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const InlineMoveHint = {
|
||||
render: () => (
|
||||
<div class="flex w-[696px] max-w-full flex-col items-start gap-4">
|
||||
<BackgroundMoveHint keybind={["Ctrl", "B"]} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SummaryPanelEntry = {
|
||||
render: () => (
|
||||
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<BackgroundWorkSummary tasks={tasks} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { createEffect, on, onCleanup } from "solid-js"
|
||||
@@ -15,31 +16,23 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
const [state, setState] = createStore({
|
||||
messageID: undefined as string | undefined,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
gestureAt: 0,
|
||||
scroll: {
|
||||
overflow: false,
|
||||
jump: false,
|
||||
},
|
||||
follow: {
|
||||
sessionKey: session.identity.sessionKey(),
|
||||
pinned: true,
|
||||
},
|
||||
refs: {
|
||||
content: undefined as HTMLDivElement | undefined,
|
||||
dock: undefined as HTMLDivElement | undefined,
|
||||
},
|
||||
})
|
||||
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
|
||||
// it from scroll geometry; everything else only expresses explicit intent.
|
||||
const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
|
||||
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
|
||||
const unpin = () => {
|
||||
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
|
||||
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false })
|
||||
}
|
||||
const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "none" })
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
let revealMessage = (_id: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
let scrollStateFrame: number | undefined
|
||||
@@ -109,10 +102,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pendingMessage: () => state.pendingMessage,
|
||||
setPendingMessage: (value) => setState("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
follow: {
|
||||
unpin,
|
||||
toBottom: () => {
|
||||
pin()
|
||||
autoScroll: {
|
||||
pause: autoScroll.pause,
|
||||
forceScrollToBottom: () => {
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
},
|
||||
},
|
||||
@@ -124,7 +117,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
})
|
||||
const resume = () => {
|
||||
setState("messageID", undefined)
|
||||
pin()
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
clearMessageHash()
|
||||
if (scroller) scheduleScrollState(scroller)
|
||||
@@ -140,23 +133,21 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resume()
|
||||
return
|
||||
}
|
||||
unpin()
|
||||
autoScroll.pause()
|
||||
scrollToMessage(messages[target], "auto")
|
||||
}
|
||||
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
|
||||
const markUserScroll = (target?: EventTarget | null) => {
|
||||
const shouldAnchorBottom = () =>
|
||||
!location.hash && !state.messageID && !state.pendingMessage && !autoScroll.userScrolled()
|
||||
const markGesture = (target?: EventTarget | null) => {
|
||||
if (!scroller) return
|
||||
const element = target instanceof Element ? target : undefined
|
||||
const nested = element?.closest("[data-scrollable]")
|
||||
if (nested && nested !== scroller) return
|
||||
scrollMark += 1
|
||||
}
|
||||
const selectionInteraction = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.toString().length > 0) unpin()
|
||||
setState("gestureAt", Date.now())
|
||||
}
|
||||
const setScrollRef = (element: HTMLDivElement | undefined) => {
|
||||
scroller = element
|
||||
autoScroll.scrollRef(element)
|
||||
if (!element) return
|
||||
scheduleScrollState(element)
|
||||
fill()
|
||||
@@ -167,12 +158,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
historyRequests.add(owner.key)
|
||||
const before = timeline.messages().length
|
||||
try {
|
||||
await timeline.history.loadOlder()
|
||||
await timeline.history.loadOlder({
|
||||
before: () => owner.run(captureHistoryAnchor),
|
||||
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
|
||||
})
|
||||
} finally {
|
||||
historyRequests.delete(owner.key)
|
||||
}
|
||||
if (!owner.current() || timeline.messages().length <= before) return
|
||||
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
@@ -183,7 +177,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
if (
|
||||
historyRequests.has(session.ownership.key()) ||
|
||||
timeline.history.loading() ||
|
||||
pinned() ||
|
||||
!autoScroll.userScrolled() ||
|
||||
!scroller ||
|
||||
scroller.scrollTop >= 200
|
||||
)
|
||||
@@ -195,7 +189,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
fillFrame = requestAnimationFrame(() => {
|
||||
fillFrame = undefined
|
||||
if (!session.identity.params.id || !timeline.ready()) return
|
||||
if (!pinned() || timeline.history.loading() || !scroller) return
|
||||
if (autoScroll.userScrolled() || timeline.history.loading() || !scroller) return
|
||||
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
|
||||
void loadOlder()
|
||||
})
|
||||
@@ -213,10 +207,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
on(
|
||||
session.identity.sessionKey,
|
||||
() => {
|
||||
pin()
|
||||
setState("messageID", undefined)
|
||||
setState("pendingMessage", undefined)
|
||||
setState("scroll", { overflow: false, jump: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -226,16 +218,15 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
() => session.identity.params.id,
|
||||
(id, previous) => {
|
||||
if (!id || !previous || id === previous || state.messageID || state.pendingMessage || location.hash) return
|
||||
pin()
|
||||
scrollToEnd()
|
||||
autoScroll.resume()
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
pinned,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
autoScroll.userScrolled,
|
||||
(scrolled) => {
|
||||
if (scrolled) return
|
||||
setState("messageID", undefined)
|
||||
clearMessageHash()
|
||||
},
|
||||
@@ -250,11 +241,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
timeline.ready(),
|
||||
timeline.history.more(),
|
||||
timeline.history.loading(),
|
||||
pinned(),
|
||||
autoScroll.userScrolled(),
|
||||
visibleUserMessages().length,
|
||||
] as const,
|
||||
([id, ready, more, loading, following]) => {
|
||||
if (id && ready && more && !loading && following) fill()
|
||||
([id, ready, more, loading, scrolled]) => {
|
||||
if (id && ready && more && !loading && !scrolled) fill()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -273,7 +264,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
if (next === dockHeight) return
|
||||
const delta = next - dockHeight
|
||||
const stick = scroller
|
||||
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
|
||||
? !autoScroll.userScrolled() ||
|
||||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
dockHeight = next
|
||||
if (stick) scrollToEnd()
|
||||
@@ -293,6 +285,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
autoScroll,
|
||||
lastUserMessage: timeline.lastUserMessage,
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
@@ -300,19 +293,25 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
scroller: () => scroller,
|
||||
view: {
|
||||
anchor,
|
||||
markUserScroll,
|
||||
hasGesture: () => Date.now() - state.gestureAt < 250,
|
||||
markGesture,
|
||||
markUserScroll: () => {
|
||||
scrollMark += 1
|
||||
},
|
||||
onHistoryScroll,
|
||||
pin,
|
||||
pinned,
|
||||
selectionInteraction,
|
||||
scheduleScrollState,
|
||||
setContentRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "content", element)
|
||||
autoScroll.contentRef(element)
|
||||
if (scroller) scheduleScrollState(scroller)
|
||||
},
|
||||
setDockRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "dock", element)
|
||||
},
|
||||
setHistoryAnchor: (handlers: { capture: () => void; restore: (done: boolean) => void }) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
},
|
||||
setRevealMessage: (reveal: (id: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
@@ -320,7 +319,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setScrollToEnd: (scroll: () => void) => {
|
||||
scrollToEnd = scroll
|
||||
},
|
||||
unpin,
|
||||
shouldAnchorBottom,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
|
||||
test("does not measure an element detached before the frame", async () => {
|
||||
const element = document.createElement("div")
|
||||
document.body.append(element)
|
||||
let calls = 0
|
||||
|
||||
scheduleConnectedMeasure(element, () => {
|
||||
calls += 1
|
||||
})
|
||||
element.remove()
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
test("measures a connected element on the next frame", async () => {
|
||||
const element = document.createElement("div")
|
||||
document.body.append(element)
|
||||
let calls = 0
|
||||
|
||||
scheduleConnectedMeasure(element, () => {
|
||||
calls += 1
|
||||
})
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
element.remove()
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
|
||||
return requestAnimationFrame(() => {
|
||||
if (element.isConnected) measure(element)
|
||||
})
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
import { createEffect, createMemo, createSignal, on, Show, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
@@ -17,111 +13,17 @@ import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
|
||||
type BackgroundTask = {
|
||||
id: string
|
||||
type: "shell" | "subagent"
|
||||
label: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
type SessionBackground = {
|
||||
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
|
||||
tasks: Accessor<BackgroundTask[]>
|
||||
move: () => Promise<void>
|
||||
}
|
||||
|
||||
export function BackgroundMoveHint(props: { keybind?: string[] }) {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
|
||||
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
|
||||
const keys = () => props.keybind ?? command.keybindParts("session.background")
|
||||
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="session-background-hint"
|
||||
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
|
||||
>
|
||||
<span data-slot="session-background-hint-prefix" class="shrink-0">
|
||||
{parts()[0].trim()}
|
||||
</span>
|
||||
<Keybind keys={keys()} variant="neutral" />
|
||||
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
if (task.type === "shell") return language.t("ui.tool.shell")
|
||||
if (!task.agent) return language.t("ui.tool.agent.default")
|
||||
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Popover.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
|
||||
>
|
||||
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
|
||||
{props.tasks.length}
|
||||
</Badge>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.t("session.background.running")}
|
||||
active
|
||||
class="min-w-0 flex-1 truncate text-start"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
|
||||
>
|
||||
<For each={props.tasks.slice(0, 10)}>
|
||||
{(task) => (
|
||||
<div
|
||||
data-component="session-background-list-item"
|
||||
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
|
||||
>
|
||||
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
@@ -182,7 +84,6 @@ function WorkspaceMoveAction(props: {
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
@@ -193,7 +94,6 @@ function SessionSummaryPanel(props: {
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
backgroundTasks: BackgroundTask[]
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => {
|
||||
@@ -209,13 +109,11 @@ function SessionSummaryPanel(props: {
|
||||
<div data-component="session-summary-panel" class="w-[280px]">
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
@@ -270,9 +168,6 @@ function SessionSummaryPanel(props: {
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.backgroundTasks.length > 0}>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
@@ -291,18 +186,18 @@ function SessionSummaryPanel(props: {
|
||||
|
||||
type MessageTimelineProps = {
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
actions?: SessionUserActions
|
||||
scroll: { overflow: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
onPin: () => void
|
||||
onUnpin: () => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: boolean
|
||||
onUserScroll: () => void
|
||||
onHistoryScroll: () => void
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
pinned: boolean
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
shouldAnchorBottom: boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
|
||||
@@ -312,6 +207,7 @@ type MessageTimelineProps = {
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
@@ -330,8 +226,6 @@ function MessageTimelineView(
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const sessionID = props.data.sessionID
|
||||
const sessionStatus = props.data.status
|
||||
@@ -350,21 +244,6 @@ function MessageTimelineView(
|
||||
return { ...value, worktree: value.canonical, worktrees: [] }
|
||||
})
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const showProjectIcon = () =>
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
|
||||
const avatarProject = createMemo(() => {
|
||||
if (!showProjectIcon()) return
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list())
|
||||
})
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(avatarProject() ?? { worktree: sessionDirectory() })}
|
||||
src={getProjectAvatarSource(avatarProject()?.id, avatarProject()?.icon)}
|
||||
variant={getProjectAvatarVariant(avatarProject()?.icon?.color)}
|
||||
/>
|
||||
)
|
||||
createEffect(() => {
|
||||
const directory = project()?.worktree
|
||||
if (!directory) return
|
||||
@@ -385,25 +264,28 @@ function MessageTimelineView(
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||
const pinned = createMemo(() => props.pinned)
|
||||
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
||||
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
sessionKey: props.data.sessionKey,
|
||||
projection,
|
||||
showHeader,
|
||||
pinned,
|
||||
shouldAnchorBottom,
|
||||
hasScrollGesture,
|
||||
scroll: () => props.scroll,
|
||||
onResumeScroll: props.onResumeScroll,
|
||||
setScrollRef: props.setScrollRef,
|
||||
setContentRef: props.setContentRef,
|
||||
onScheduleScrollState: props.onScheduleScrollState,
|
||||
onPin: props.onPin,
|
||||
onUnpin: props.onUnpin,
|
||||
onSelectionInteraction: props.onSelectionInteraction,
|
||||
onAutoScrollHandleScroll: props.onAutoScrollHandleScroll,
|
||||
onAutoScrollInteraction: props.onAutoScrollInteraction,
|
||||
onMarkScrollGesture: props.onMarkScrollGesture,
|
||||
onUserScroll: props.onUserScroll,
|
||||
onHistoryScroll: props.onHistoryScroll,
|
||||
setRevealMessage: props.setRevealMessage,
|
||||
setScrollToEnd: props.setScrollToEnd,
|
||||
setHistoryAnchor: props.setHistoryAnchor,
|
||||
})
|
||||
const VirtualizedTimeline = virtualized.View
|
||||
const [title, setTitle] = createStore({
|
||||
@@ -468,63 +350,21 @@ function MessageTimelineView(
|
||||
padding: turnPadding,
|
||||
anchor: props.anchor,
|
||||
})
|
||||
const backgroundHintPartID = createMemo(() => {
|
||||
const blocking = new Set(props.background.blocking().map((task) => task.partID))
|
||||
const row = projection
|
||||
.rows()
|
||||
.findLast(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
|
||||
)
|
||||
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
|
||||
return row.group.ref.partID
|
||||
})
|
||||
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
|
||||
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
|
||||
(previous) => {
|
||||
const show = backgroundHintPartID() !== undefined
|
||||
return { show, animate: previous.animate || previous.show !== show }
|
||||
},
|
||||
{ show: backgroundHintPartID() !== undefined, animate: false },
|
||||
)
|
||||
const backgroundHintPresence = createPresence({
|
||||
show: () => backgroundHintVisibility().show,
|
||||
element: () => backgroundHintRef() ?? null,
|
||||
})
|
||||
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
data-component="session-background-hint-row"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
|
||||
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
deferred={(row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
|
||||
return content?.type === "tool" && ["edit", "write"].includes(content.name)
|
||||
return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name)
|
||||
}}
|
||||
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
|
||||
header={
|
||||
<SessionTitleHeader>
|
||||
<div
|
||||
data-session-title
|
||||
class="sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] w-full pb-4 pr-3 pl-2.5"
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
@@ -532,9 +372,7 @@ function MessageTimelineView(
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
<Icon name="monitor" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -546,14 +384,9 @@ function MessageTimelineView(
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
<Icon name="workspace-isolated" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
@@ -639,7 +472,6 @@ function MessageTimelineView(
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
@@ -653,7 +485,6 @@ function MessageTimelineView(
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
@@ -714,7 +545,7 @@ function MessageTimelineView(
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
enrichLeadingTurn,
|
||||
leadingTurnNeedsParent,
|
||||
loadOlderTimeline,
|
||||
selectUserMessages,
|
||||
selectVisibleUserMessages,
|
||||
} from "./model"
|
||||
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||
|
||||
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
|
||||
const assistant = (id: string): SessionMessageAssistant => ({
|
||||
@@ -48,56 +42,6 @@ describe("timeline model", () => {
|
||||
expect(anchors).toEqual(["before", "after", true])
|
||||
})
|
||||
|
||||
test("recognizes a leading partial assistant turn", () => {
|
||||
expect(leadingTurnNeedsParent([assistant("msg_assistant"), user("msg_next")])).toBe(true)
|
||||
expect(leadingTurnNeedsParent([user("msg_user"), assistant("msg_assistant")])).toBe(false)
|
||||
expect(leadingTurnNeedsParent([user("msg_user")])).toBe(false)
|
||||
})
|
||||
|
||||
test("pauses between bounded history pages until the leading turn has its parent", async () => {
|
||||
const pages: SessionMessageInfo[][] = [[assistant("msg_older")], [user("msg_parent")]]
|
||||
const messages: SessionMessageInfo[] = [assistant("msg_latest"), user("msg_next")]
|
||||
let pauses = 0
|
||||
let loads = 0
|
||||
|
||||
await enrichLeadingTurn({
|
||||
current: () => true,
|
||||
messages: () => messages,
|
||||
more: () => pages.length > 0,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
messages.unshift(...pages.shift()!)
|
||||
loads += 1
|
||||
},
|
||||
pause: async () => {
|
||||
pauses += 1
|
||||
},
|
||||
maxPages: 3,
|
||||
})
|
||||
|
||||
expect(loads).toBe(2)
|
||||
expect(pauses).toBe(2)
|
||||
expect(leadingTurnNeedsParent(messages)).toBe(false)
|
||||
})
|
||||
|
||||
test("caps background pages when the parent remains outside the window", async () => {
|
||||
let loads = 0
|
||||
|
||||
await enrichLeadingTurn({
|
||||
current: () => true,
|
||||
messages: () => [assistant("msg_latest")],
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
loads += 1
|
||||
},
|
||||
pause: async () => undefined,
|
||||
maxPages: 3,
|
||||
})
|
||||
|
||||
expect(loads).toBe(3)
|
||||
})
|
||||
|
||||
test("does not restore an anchor after the session changes", async () => {
|
||||
let sessionID = "ses_old"
|
||||
let restore = 0
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { createMemo, createResource, type Accessor } from "solid-js"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
const leadingTurnPageDelay = 200
|
||||
const leadingTurnPageLimit = 3
|
||||
|
||||
export {
|
||||
selectSessionUserMessages as selectUserMessages,
|
||||
selectVisibleSessionUserMessages as selectVisibleUserMessages,
|
||||
@@ -13,32 +9,12 @@ export {
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
|
||||
const data = useData()
|
||||
const prepared = new Set<string>()
|
||||
|
||||
const [resource] = createResource(
|
||||
() => input.session.identity.sessionID(),
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
const key = input.session.identity.sessionKey()
|
||||
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
|
||||
await enrichLeadingTurn({
|
||||
current: () => input.session.identity.sessionKey() === key,
|
||||
messages: () => data.session.message.list(id),
|
||||
more: () => data.session.message.more(id),
|
||||
loading: () => data.session.message.loading(id),
|
||||
loadMore: () => data.session.message.loadMore(id),
|
||||
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
|
||||
maxPages: leadingTurnPageLimit,
|
||||
}).catch(() => undefined)
|
||||
if (input.session.identity.sessionKey() === key) prepared.add(key)
|
||||
},
|
||||
(id) => (id ? Promise.all([data.session.message.sync(id), data.session.pending.sync(id)]) : undefined),
|
||||
)
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
|
||||
const messages = data.session.message.list(id)
|
||||
return messages.length > 0 && !leadingTurnNeedsParent(messages)
|
||||
})
|
||||
const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading)
|
||||
const more = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
return id ? data.session.message.more(id) : false
|
||||
@@ -52,7 +28,7 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
sessionID: input.session.identity.sessionID,
|
||||
more,
|
||||
loading,
|
||||
loadMore: (id) => data.session.message.loadMore(id),
|
||||
loadMore: data.session.message.loadMore,
|
||||
before: options?.before,
|
||||
after: options?.after,
|
||||
})
|
||||
@@ -68,34 +44,6 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichLeadingTurn(input: {
|
||||
current: Accessor<boolean>
|
||||
messages: Accessor<SessionMessageInfo[]>
|
||||
more: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
loadMore: () => Promise<void>
|
||||
pause: () => Promise<void>
|
||||
maxPages: number
|
||||
}) {
|
||||
const load = async (pages: number): Promise<void> => {
|
||||
if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more())
|
||||
return
|
||||
await input.pause()
|
||||
if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return
|
||||
if (input.loading()) return load(pages)
|
||||
await input.loadMore()
|
||||
return load(pages + 1)
|
||||
}
|
||||
return load(0)
|
||||
}
|
||||
|
||||
export function leadingTurnNeedsParent(messages: SessionMessageInfo[]) {
|
||||
const assistant = messages.findIndex((message) => message.type === "assistant")
|
||||
if (assistant === -1) return false
|
||||
const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell")
|
||||
return boundary === -1 || assistant < boundary
|
||||
}
|
||||
|
||||
export async function loadOlderTimeline(input: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
more: Accessor<boolean>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
|
||||
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
|
||||
instance: Virtualizer<TScrollElement, TItemElement>,
|
||||
callback: (offset: number, isScrolling: boolean) => void,
|
||||
onReconnect?: () => void,
|
||||
) {
|
||||
let active = true
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
@@ -55,7 +54,6 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
|
||||
}
|
||||
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
|
||||
removed = false
|
||||
onReconnect?.()
|
||||
startCheck()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,8 +63,7 @@ export function createTimelineProjection(input: {
|
||||
input.sessionMessages().forEach((message) => {
|
||||
if (message.type === "user") userID = message.id
|
||||
if (message.type === "shell") userID = undefined
|
||||
if (message.type !== "assistant") return
|
||||
if (!userID) userID = message.id
|
||||
if (message.type !== "assistant" || !userID) return
|
||||
const messages = result.get(userID)
|
||||
if (messages) {
|
||||
messages.push(message)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/session/message-gesture"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
createEffect,
|
||||
@@ -16,15 +17,11 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
|
||||
const fallbackItemSize = 60
|
||||
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
|
||||
// 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<
|
||||
@@ -36,25 +33,25 @@ type Input = {
|
||||
sessionKey: Accessor<string>
|
||||
projection: Projection
|
||||
showHeader: Accessor<boolean>
|
||||
/** True while the timeline follows the newest content. Drives every anchoring decision. */
|
||||
pinned: Accessor<boolean>
|
||||
shouldAnchorBottom: Accessor<boolean>
|
||||
hasScrollGesture: Accessor<boolean>
|
||||
scroll: Accessor<{ overflow: boolean; jump: boolean }>
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (element: HTMLDivElement | undefined) => void
|
||||
setContentRef: (element: HTMLDivElement) => void
|
||||
onScheduleScrollState: (element: HTMLDivElement) => void
|
||||
onPin: () => void
|
||||
onUnpin: () => void
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
onUserScroll: () => void
|
||||
onHistoryScroll: () => void
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}
|
||||
|
||||
type ViewProps = {
|
||||
header: JSX.Element
|
||||
bottomSpacer?: JSX.Element
|
||||
workspaceSession: Accessor<boolean>
|
||||
deferred: (row: TimelineRow.TimelineRow) => boolean
|
||||
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
|
||||
@@ -65,31 +62,88 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
const cached = cache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
const knownKeys = new Set(rows().map(TimelineRow.key))
|
||||
const addedKeys = new Set<string>()
|
||||
let touchStart: number | undefined
|
||||
let pointerHeld = false
|
||||
let maxScroll = 0
|
||||
let touchGesture: number | undefined
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
let prependLoading = false
|
||||
let resizePinnedIndexes: number[] = []
|
||||
let resizePinFrame: number | undefined
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
let scrollTop = 0
|
||||
|
||||
const clearPrependAnchor = () => {
|
||||
prependLoading = false
|
||||
prependAnchor = undefined
|
||||
if (prependAnchorFrame === undefined) return
|
||||
cancelAnimationFrame(prependAnchorFrame)
|
||||
prependAnchorFrame = undefined
|
||||
}
|
||||
|
||||
const capturePrependAnchor = () => {
|
||||
prependLoading = true
|
||||
updatePrependAnchor()
|
||||
}
|
||||
|
||||
const updatePrependAnchor = () => {
|
||||
const root = listRoot()
|
||||
if (!root) return
|
||||
const view = root.getBoundingClientRect()
|
||||
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
||||
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)[0]
|
||||
if (!anchor) return
|
||||
if (!anchor.element.dataset.timelineKey) return
|
||||
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
|
||||
}
|
||||
|
||||
const restorePrependAnchor = (done: boolean) => {
|
||||
if (done) prependLoading = false
|
||||
applyPrependAnchor()
|
||||
}
|
||||
|
||||
const applyPrependAnchor = () => {
|
||||
const root = listRoot()
|
||||
if (!root || !prependAnchor) return
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
let frames = 0
|
||||
let stable = 0
|
||||
const apply = () => {
|
||||
prependAnchorFrame = undefined
|
||||
const anchor = prependAnchor
|
||||
if (!anchor) return
|
||||
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
|
||||
const delta = element
|
||||
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
|
||||
: undefined
|
||||
if (delta !== undefined && Math.abs(delta) > 0.5) {
|
||||
root.scrollTop += delta
|
||||
stable = 0
|
||||
} else {
|
||||
stable += 1
|
||||
}
|
||||
frames += 1
|
||||
if (stable >= 30 || frames >= 180) {
|
||||
if (!prependLoading) prependAnchor = undefined
|
||||
return
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
observeElementOffset: (instance, callback) =>
|
||||
observeElementOffsetReconnectAware(instance, callback, () => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
}),
|
||||
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
observeElementOffset: observeElementOffsetReconnectAware,
|
||||
initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => fallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
@@ -98,66 +152,67 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
get getItemKey() {
|
||||
const items = rows()
|
||||
items
|
||||
.map(TimelineRow.key)
|
||||
.filter((key) => !knownKeys.has(key))
|
||||
.forEach((key) => {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
return (index: number) => {
|
||||
const row = items[index]
|
||||
if (!row) return `removed:${index}`
|
||||
return TimelineRow.key(row)
|
||||
}
|
||||
},
|
||||
get anchorTo() {
|
||||
return input.pinned() ? "end" : "start"
|
||||
},
|
||||
get followOnAppend() {
|
||||
return input.pinned()
|
||||
},
|
||||
anchorTo: "end",
|
||||
followOnAppend: true,
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return input.showHeader() ? 64 : 0
|
||||
},
|
||||
overscan: 50,
|
||||
paddingEnd: 64,
|
||||
rangeExtractor: (range) => {
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
|
||||
// reconciles the end. Coalesce one correction per measurement batch, before paint.
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled) return
|
||||
if (resizeAnchorScheduled || input.hasScrollGesture()) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (!input.pinned()) return
|
||||
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
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 (listRoot() && input.pinned()) anchorResizedBottom()
|
||||
if (root && input.shouldAnchorBottom()) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
// Prepended rows can resize more than once as deferred content mounts. Keep
|
||||
// compensating while they remain entirely above the visible content fold.
|
||||
if (addedKeys.has(String(item.key)))
|
||||
return (
|
||||
item.end <=
|
||||
(instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
|
||||
)
|
||||
const first = instance.range?.startIndex
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (input.shouldAnchorBottom()) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
const virtualItemByKey = createMemo(
|
||||
@@ -171,110 +226,92 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (index === undefined) return
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
input.setScrollToEnd?.(() => {
|
||||
input.onPin()
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
input.setScrollToEnd?.(() => virtualizer.scrollToEnd())
|
||||
input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
|
||||
})
|
||||
|
||||
let overscanFrame: number | undefined
|
||||
const pendingMeasurements = () =>
|
||||
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
|
||||
const settleColdBottom = () => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
|
||||
overscanFrame = requestAnimationFrame(settleColdBottom)
|
||||
return
|
||||
}
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
|
||||
settleColdBottom()
|
||||
return
|
||||
}
|
||||
overscanFrame = undefined
|
||||
const content = virtualContent
|
||||
if (!content) return
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
content.style.removeProperty("visibility")
|
||||
return
|
||||
}
|
||||
const animation = ["animate-in", "fade-in", "duration-150"]
|
||||
const clearAnimation = (event: AnimationEvent) => {
|
||||
if (event.target !== content) return
|
||||
content.removeEventListener("animationend", clearAnimation)
|
||||
content.removeEventListener("animationcancel", clearAnimation)
|
||||
content.classList.remove(...animation)
|
||||
}
|
||||
content.addEventListener("animationend", clearAnimation)
|
||||
content.addEventListener("animationcancel", clearAnimation)
|
||||
content.classList.add(...animation)
|
||||
content.style.removeProperty("visibility")
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
if (!coldBottomMount) {
|
||||
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
return
|
||||
}
|
||||
settleColdBottom()
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
if (rows().length === 0) return
|
||||
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = input.sessionKey()
|
||||
createEffect(() => {
|
||||
const key = input.sessionKey()
|
||||
rows().length
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
virtualizer.measure()
|
||||
}
|
||||
maybeAnchorBottom()
|
||||
})
|
||||
|
||||
const bindListRoot = (root: HTMLDivElement) => {
|
||||
if (root === listRoot()) return
|
||||
setListRoot(root)
|
||||
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
|
||||
root.style.overflowAnchor = "none"
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
input.setScrollRef(root)
|
||||
}
|
||||
|
||||
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
|
||||
// the end must stop following, even though the resulting position still looks like the end.
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
if (event.deltaY < 0) input.onUnpin()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture })
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
input.onUserScroll(event.target)
|
||||
touchStart = event.touches[0]?.clientY
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const current = event.touches[0]?.clientY
|
||||
if (current === undefined || touchStart === undefined) return
|
||||
// Dragging the content downward reveals earlier messages.
|
||||
if (current <= touchStart) return
|
||||
touchStart = current
|
||||
input.onUnpin()
|
||||
const next = event.touches[0]?.clientY
|
||||
const previous = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || previous === undefined) return
|
||||
const delta = previous - next
|
||||
if (!delta) return
|
||||
markBoundaryGesture({
|
||||
root: event.currentTarget,
|
||||
target: event.target,
|
||||
delta,
|
||||
onMarkScrollGesture: input.onMarkScrollGesture,
|
||||
})
|
||||
}
|
||||
|
||||
const handleListTouchEnd = () => {
|
||||
touchGesture = undefined
|
||||
}
|
||||
|
||||
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
|
||||
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
pointerHeld = true
|
||||
const release = () => {
|
||||
pointerHeld = false
|
||||
window.removeEventListener("pointerup", release)
|
||||
window.removeEventListener("pointercancel", release)
|
||||
}
|
||||
window.addEventListener("pointerup", release)
|
||||
window.addEventListener("pointercancel", release)
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
input.onMarkScrollGesture(event.target)
|
||||
}
|
||||
|
||||
const handleListPointerMove = (event: PointerEvent) => {
|
||||
if (event.buttons !== 1) return
|
||||
input.onMarkScrollGesture(event.target)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -282,25 +319,18 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!key) return
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
input.onUserScroll(event.currentTarget)
|
||||
if (upwardKeys.has(key)) input.onUnpin()
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
input.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
|
||||
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
|
||||
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
const root = event.currentTarget
|
||||
const previousTop = scrollTop
|
||||
const previousMaxScroll = maxScroll
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
const atEnd = maxScroll - scrollTop <= endEpsilon
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
input.onScheduleScrollState(root)
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
input.onScheduleScrollState(event.currentTarget)
|
||||
input.onHistoryScroll()
|
||||
if (!input.hasScrollGesture()) return
|
||||
input.onUserScroll()
|
||||
input.onAutoScrollHandleScroll()
|
||||
input.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
function View(props: ViewProps) {
|
||||
@@ -351,10 +381,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
{props.renderRow(row, () => {
|
||||
setReady(true)
|
||||
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
|
||||
contentMeasureFrame = requestAnimationFrame(() => {
|
||||
contentMeasureFrame = undefined
|
||||
if (element.isConnected) virtualizer.measureElement(element)
|
||||
})
|
||||
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,37 +423,33 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
onTouchMove={handleListTouchMove}
|
||||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onPointerMove={handleListPointerMove}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={input.onSelectionInteraction}
|
||||
onClick={input.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
|
||||
>
|
||||
<Show when={input.showHeader()}>{props.header}</Show>
|
||||
<div
|
||||
data-timeline-virtual-content
|
||||
class="motion-reduce:animate-none"
|
||||
ref={(element) => {
|
||||
virtualContent = element
|
||||
input.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
visibility: coldBottomMount ? "hidden" : undefined,
|
||||
}}
|
||||
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}
|
||||
>
|
||||
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
|
||||
<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>
|
||||
@@ -435,13 +458,16 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clearPrependAnchor()
|
||||
cache.delete(ownerSessionKey)
|
||||
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -452,3 +478,30 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
View,
|
||||
}
|
||||
}
|
||||
|
||||
function boundaryTarget(root: HTMLElement, target: EventTarget | null) {
|
||||
const current = target instanceof Element ? target : undefined
|
||||
const nested = current?.closest("[data-scrollable]")
|
||||
if (!(nested instanceof HTMLElement) || nested === root) return undefined
|
||||
return nested
|
||||
}
|
||||
|
||||
function markBoundaryGesture(input: {
|
||||
root: HTMLElement
|
||||
target: EventTarget | null
|
||||
delta: number
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
}) {
|
||||
const target = boundaryTarget(input.root, input.target)
|
||||
if (
|
||||
target &&
|
||||
!shouldMarkBoundaryGesture({
|
||||
delta: input.delta,
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
})
|
||||
)
|
||||
return
|
||||
input.onMarkScrollGesture(input.root)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useSessionHashScroll = (input: {
|
||||
pendingMessage: () => string | undefined
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: SessionMessageUser | undefined) => void
|
||||
follow: { unpin: () => void; toBottom: () => void }
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
revealMessage?: (id: string) => void
|
||||
@@ -101,7 +101,7 @@ export const useSessionHashScroll = (input: {
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = location.hash.slice(1)
|
||||
if (!hash) {
|
||||
input.follow.toBottom()
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
return
|
||||
@@ -109,7 +109,7 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const messageId = messageIdFromHash(hash)
|
||||
if (messageId) {
|
||||
input.follow.unpin()
|
||||
input.autoScroll.pause()
|
||||
const msg = messageById().get(messageId)
|
||||
if (msg) {
|
||||
scrollToMessage(msg, behavior)
|
||||
@@ -120,12 +120,12 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const target = document.getElementById(hash)
|
||||
if (target) {
|
||||
input.follow.unpin()
|
||||
input.autoScroll.pause()
|
||||
scrollToElement(target, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
input.follow.toBottom()
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export const useSessionHashScroll = (input: {
|
||||
if (pending) input.setPendingMessage(undefined)
|
||||
if (input.currentMessageId() === targetId && !pending) return
|
||||
|
||||
input.follow.unpin()
|
||||
input.autoScroll.pause()
|
||||
cancel()
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -123,33 +123,6 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props)
|
||||
)
|
||||
}
|
||||
|
||||
const TerminalPlacementSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: TerminalPlacement; label: string }[] => [
|
||||
{ value: "side", label: language.t("settings.general.row.terminalPlacement.side") },
|
||||
{ value: "bottom", label: language.t("settings.general.row.terminalPlacement.bottom") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.terminalPlacement.title")}
|
||||
description={language.t("settings.general.row.terminalPlacement.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-terminal-placement"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.general.terminalPlacement())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.general.setTerminalPlacement(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -300,7 +273,6 @@ export const SettingsGeneral: Component<{
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<TerminalPlacementSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
@@ -338,20 +310,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
description={language.t("settings.general.row.showProjectIcon.description")}
|
||||
>
|
||||
<div data-action="settings-show-project-icon">
|
||||
<Switch
|
||||
checked={settings.general.showProjectIcon()}
|
||||
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||
export type WorkspaceLastUsed = "local" | "workspace"
|
||||
export type TerminalPlacement = "side" | "bottom"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -31,14 +30,12 @@ export interface Settings {
|
||||
showNavigation: boolean
|
||||
showSearch: boolean
|
||||
showStatus: boolean
|
||||
showProjectIcon: boolean
|
||||
showTerminal: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
terminalPlacement: TerminalPlacement
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -118,14 +115,12 @@ const defaultSettings: Settings = {
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -209,10 +204,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
|
||||
setShowProjectIcon(value: boolean) {
|
||||
setStore("general", "showProjectIcon", value)
|
||||
},
|
||||
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
@@ -249,13 +240,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
setStore("general", "mobileTitlebarPosition", value)
|
||||
},
|
||||
terminalPlacement: withFallback(
|
||||
() => store.general?.terminalPlacement,
|
||||
defaultSettings.general.terminalPlacement,
|
||||
),
|
||||
setTerminalPlacement(value: TerminalPlacement) {
|
||||
setStore("general", "terminalPlacement", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
@@ -281,7 +281,7 @@
|
||||
padding-block: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
padding-inline-end: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
@@ -307,7 +307,7 @@
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-accent);
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
@@ -329,7 +329,7 @@
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-providers .settings-section-title + [data-component="settings-list"] {
|
||||
@@ -478,7 +478,7 @@
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -495,7 +495,7 @@
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-shortcuts [data-component="settings-list"] {
|
||||
@@ -525,7 +525,7 @@
|
||||
.settings-shortcuts [data-component="settings-list"] > div > span {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
@@ -577,7 +577,7 @@
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -654,7 +654,7 @@
|
||||
.settings-servers-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
@@ -674,7 +674,7 @@
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -784,7 +784,7 @@
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -796,7 +796,7 @@
|
||||
.settings-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@@ -933,7 +933,7 @@
|
||||
.settings-server-dialog-label {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header > :last-child {
|
||||
|
||||
@@ -96,7 +96,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -150,7 +150,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
|
||||
@@ -93,24 +93,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
activeDirectory: props.activeDirectory,
|
||||
}
|
||||
}
|
||||
// Fetch sessions per workspace directory instead of paging through every session on the server.
|
||||
const loadSessions = async (directories: readonly string[], context = captureDeleteContext()) => {
|
||||
const fetched = await Promise.all(
|
||||
directories.map((directory) => listAllSessions(context.sdk.api.session, { order: "desc", directory })),
|
||||
)
|
||||
const sessions = fetched.flat()
|
||||
return mergeWorkspaceSessionInventory(sessions, context.data.session.list())
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
fetched.forEach(context.data.session.remember)
|
||||
return mergeWorkspaceSessionInventory(fetched, context.data.session.list())
|
||||
}
|
||||
const workspaceDirectories = createMemo(() => workspaces().map((workspace) => workspace.directory))
|
||||
const sessionQuery = useQuery(() => ({
|
||||
queryKey: [
|
||||
serverSDK.scope,
|
||||
null,
|
||||
"settings-workspace-sessions",
|
||||
workspaceDirectories().map((directory) => String(pathKey(directory))),
|
||||
] as const,
|
||||
queryFn: () => loadSessions(workspaceDirectories()),
|
||||
enabled: workspaceDirectories().length > 0,
|
||||
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
|
||||
queryFn: () => loadSessions().then(() => Date.now()),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const sessionsByWorkspace = createMemo(
|
||||
@@ -118,7 +108,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
new Map(
|
||||
workspaces().map((workspace) => [
|
||||
pathKey(workspace.directory),
|
||||
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
|
||||
sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -146,7 +136,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
|
||||
const [working, branch, sessions] = await Promise.all([
|
||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||
loadSessions([workspace.directory], context),
|
||||
loadSessions(context),
|
||||
])
|
||||
const result = inspectWorkspaceDeletion({
|
||||
workspace: workspace.directory,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user