mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 18:16:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c22b9d7e | ||
|
|
67e99993f5 | ||
|
|
be6abc02b5 | ||
|
|
e012a57d1d | ||
|
|
697e3e13cc |
@@ -41,6 +41,13 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
|
||||
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
|
||||
const omitsFunctionCallIds = (modelID: string) => {
|
||||
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -217,6 +224,7 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -274,20 +282,14 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||
? google.functionCallId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
const flushMedia = () => {
|
||||
@@ -336,7 +338,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part)
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -361,7 +363,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -382,7 +384,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -581,6 +583,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
@@ -618,13 +622,13 @@ 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),
|
||||
// Gemini 2.0+ supplies 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 metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
}
|
||||
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||
const supplied = part.functionCall.id
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
@@ -637,7 +641,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||
providerMetadata:
|
||||
part.thoughtSignature === undefined ? undefined : googleMetadata({ thoughtSignature: part.thoughtSignature }),
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
@@ -651,6 +656,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
|
||||
@@ -134,7 +134,7 @@ const HOSTED_TOOLS = {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
|
||||
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export const AllowedTools = Schema.Struct({
|
||||
|
||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
|
||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
|
||||
@@ -38,13 +38,23 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* Gemini.protocol.body.from(request)
|
||||
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: content.parts.map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
}),
|
||||
}))
|
||||
const value = request.providerOptions?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, labels }
|
||||
return { ...body, contents, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"name": "azure/chat-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
|
||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { serviceTier: "premium" },
|
||||
})
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
|
||||
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { Azure } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
|
||||
|
||||
const chatModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).chat("gpt-5.6-luna")
|
||||
|
||||
const responsesModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).responses("gpt-5.6-luna")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "azure",
|
||||
provider: "azure",
|
||||
requires: ["AZURE_OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Azure OpenAI recorded", () => {
|
||||
recorded.effect("chat streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("bonjour")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -156,14 +156,13 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -201,8 +200,8 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "time" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -210,14 +209,12 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "noon" },
|
||||
},
|
||||
@@ -228,6 +225,104 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers function call ids for gemini 3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
]
|
||||
const legacy = yield* compileRequest(LLM.request({ model, messages }))
|
||||
const older = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-1.5-flash" }),
|
||||
messages,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(legacy.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
|
||||
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes function call ids for non-gemini model ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemma-3-27b-it" }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -419,13 +514,16 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
|
||||
parts: [
|
||||
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
@@ -849,7 +947,7 @@ describe("Gemini route", () => {
|
||||
})
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call",
|
||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
@@ -857,7 +955,7 @@ describe("Gemini route", () => {
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||
@@ -873,7 +971,6 @@ describe("Gemini route", () => {
|
||||
name: "lookup",
|
||||
result: "done",
|
||||
resultType: "text",
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -977,7 +1074,7 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -987,7 +1084,7 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -1023,15 +1120,15 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
@@ -1059,11 +1156,11 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -1214,7 +1311,6 @@ describe("Gemini route", () => {
|
||||
id: "call_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "call_0" } },
|
||||
})
|
||||
expect(response.toolCalls[1]).toMatchObject({
|
||||
type: "tool-call",
|
||||
@@ -1230,6 +1326,37 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls[0]).toMatchObject({
|
||||
id: "dup_call",
|
||||
providerMetadata: undefined,
|
||||
})
|
||||
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns distinct unique fallback ids across separate requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { GoogleVertex } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = GoogleVertex.configure({
|
||||
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.5-flash")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-vertex",
|
||||
provider: "google-vertex",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_VERTEX_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Google Vertex Gemini recorded", () => {
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
@@ -75,6 +75,53 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash"),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
|
||||
expect(prepared.body.contents).toMatchObject([
|
||||
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -189,6 +189,7 @@ describe("Open Responses-compatible route", () => {
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
serviceTier: "provider-tier",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
@@ -213,6 +214,7 @@ describe("Open Responses-compatible route", () => {
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: -0.1,
|
||||
truncation: "auto",
|
||||
service_tier: "provider-tier",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
|
||||
@@ -188,13 +188,13 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
it.effect("passes through provider-defined service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
expect(prepared.body.service_tier).toBe("scale")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2652,6 +2652,47 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "computer_call",
|
||||
id: "computer_1",
|
||||
call_id: "call_1",
|
||||
status: "completed",
|
||||
action: { type: "click", x: 100, y: 200 },
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
input: { type: "click", x: 100, y: 200 },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("hello"),
|
||||
})
|
||||
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
||||
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ tool },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||
|
||||
@@ -179,6 +179,7 @@ async function expectMountedTree(page: Page, total: number) {
|
||||
}
|
||||
|
||||
async function expectSideGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
|
||||
@@ -188,15 +189,20 @@ async function expectSideGeometry(page: Page) {
|
||||
terminalLeft: terminal.left,
|
||||
terminalRight: terminal.right,
|
||||
terminalTop: terminal.top,
|
||||
terminalBottom: terminal.bottom,
|
||||
reviewTop: review.top,
|
||||
reviewBottom: review.bottom,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
|
||||
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectBottomGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
@@ -226,6 +232,30 @@ async function expectBottomGeometry(page: Page) {
|
||||
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
|
||||
}
|
||||
|
||||
async function expectPanelGap(page: Page, expected: number) {
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
const gap = terminal.top - review.bottom
|
||||
return gap
|
||||
})
|
||||
})
|
||||
.toBeGreaterThanOrEqual(expected - 1)
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
})
|
||||
})
|
||||
.toBeLessThanOrEqual(expected + 1)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -243,7 +243,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
await page.getByRole("button", { name: "New terminal" }).click()
|
||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
|
||||
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -42,6 +42,16 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/animation.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
@@ -94,24 +104,454 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectWidthMotions(page, 1)
|
||||
await expectReviewWidthStable(page)
|
||||
await expectLogicalSideAlignment(page, "ltr")
|
||||
await page.evaluate(() => (document.documentElement.dir = "rtl"))
|
||||
await expectLogicalSideAlignment(page, "rtl")
|
||||
await page.evaluate(() => (document.documentElement.dir = "ltr"))
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const panel = page.locator("#terminal-panel")
|
||||
const terminalContent = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expectHeightMotions(page, "session-side-region", 1)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 1)
|
||||
await expectStackedGeometry(page)
|
||||
await expectPanelGapHeld(page)
|
||||
|
||||
await resetTerminalTopMotion(page)
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
const reviewContent = page.locator('[data-component="session-review-v2"]')
|
||||
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
await expectTerminalTopMotion(page)
|
||||
await expectTerminalBottomFixed(page)
|
||||
await expectTerminalTopAnchored(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectHeightMotions(page, "session-side-region", 3)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 3)
|
||||
|
||||
await resetTerminalContentSizes(page)
|
||||
await resetPanelGaps(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
|
||||
await expectHeightMotions(page, "session-side-region", 4)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 4)
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectTerminalContentCachedSize(page)
|
||||
await expectStackPainted(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
|
||||
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expectWidthMotions(page, 2)
|
||||
await expectSideSlideSettled(page, 2)
|
||||
await expectHiddenSideAligned(page)
|
||||
|
||||
await resetHeightMotions(page)
|
||||
await resetHorizontalScrolls(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expectWidthMotions(page, 3)
|
||||
await expectSideSlideSettled(page, 3)
|
||||
await expectNoHeightMotion(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 4)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 5)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
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 expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectAnimation(page, "terminal-panel-size-in")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expectAnimation(page, "terminal-panel-size-out")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
|
||||
})
|
||||
|
||||
type MotionProbe = {
|
||||
widths: number
|
||||
widthEnds: number
|
||||
horizontalScrolls: number[]
|
||||
reviewWidths: number[]
|
||||
paintGaps: { review: number; terminalSurface: number }[]
|
||||
terminalContentSizes: { width: number; height: number }[]
|
||||
terminalAnchorGaps: number[]
|
||||
resetAnchorOnMotion: boolean
|
||||
panelGaps: number[]
|
||||
terminalTops: number[]
|
||||
terminalBottoms: number[]
|
||||
heights: string[]
|
||||
animations: string[]
|
||||
}
|
||||
|
||||
async function installMotionProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe: MotionProbe = {
|
||||
widths: 0,
|
||||
widthEnds: 0,
|
||||
horizontalScrolls: [],
|
||||
reviewWidths: [],
|
||||
paintGaps: [],
|
||||
terminalContentSizes: [],
|
||||
terminalAnchorGaps: [],
|
||||
resetAnchorOnMotion: false,
|
||||
panelGaps: [],
|
||||
terminalTops: [],
|
||||
terminalBottoms: [],
|
||||
heights: [],
|
||||
animations: [],
|
||||
}
|
||||
const observed = new WeakSet<Element>()
|
||||
const observers: ResizeObserver[] = []
|
||||
const observeReview = () => {
|
||||
const review = document.querySelector('[data-component="session-review-v2"]')
|
||||
if (!review || observed.has(review)) return
|
||||
observed.add(review)
|
||||
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
|
||||
observer.observe(review)
|
||||
observers.push(observer)
|
||||
}
|
||||
const observedRegions = new WeakSet<Element>()
|
||||
const observeStack = () => {
|
||||
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
|
||||
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
|
||||
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
|
||||
observedRegions.add(reviewRegion)
|
||||
const observer = new ResizeObserver(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
|
||||
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
|
||||
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
|
||||
if (!terminal || !terminalContent) return
|
||||
probe.terminalTops.push(terminal.getBoundingClientRect().top)
|
||||
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
|
||||
probe.terminalContentSizes.push({
|
||||
width: terminalContent.getBoundingClientRect().width,
|
||||
height: terminalContent.getBoundingClientRect().height,
|
||||
})
|
||||
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
|
||||
if (probe.resetAnchorOnMotion) {
|
||||
if (anchorGap > 8) return
|
||||
probe.terminalAnchorGaps = []
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
|
||||
terminalSurface: Math.abs(
|
||||
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
|
||||
),
|
||||
})
|
||||
})
|
||||
observer.observe(reviewRegion)
|
||||
observer.observe(terminalRegion)
|
||||
observers.push(observer)
|
||||
}
|
||||
new MutationObserver(() => {
|
||||
observeReview()
|
||||
observeStack()
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
observeReview()
|
||||
observeStack()
|
||||
document.addEventListener("transitionrun", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const slot = event.target.getAttribute("data-slot")
|
||||
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
|
||||
if (event.propertyName === "height" && slot) {
|
||||
probe.heights.push(slot)
|
||||
}
|
||||
})
|
||||
document.addEventListener("transitionend", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
|
||||
probe.widthEnds++
|
||||
})
|
||||
document.addEventListener("animationstart", (event) => {
|
||||
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
|
||||
probe.animations.push(event.animationName)
|
||||
})
|
||||
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
|
||||
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
|
||||
})
|
||||
}
|
||||
|
||||
async function expectWidthMotions(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function resetHeightMotions(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.heights = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectSideSlideSettled(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectNoHeightMotion(page: Page) {
|
||||
const heights = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
|
||||
)
|
||||
expect(heights).toEqual([])
|
||||
}
|
||||
|
||||
async function expectHiddenSideAligned(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
|
||||
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
|
||||
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
|
||||
const row = chat.parentElement.getBoundingClientRect()
|
||||
const hidden = side.getBoundingClientRect()
|
||||
return Math.max(
|
||||
Math.abs(row.top - hidden.top),
|
||||
Math.abs(row.right - hidden.right),
|
||||
Math.abs(row.bottom - hidden.bottom),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetHorizontalScrolls(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.horizontalScrolls = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoHorizontalScroll(page: Page) {
|
||||
const scrolls = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
|
||||
)
|
||||
expect(Math.max(0, ...scrolls)).toBe(0)
|
||||
expect(await page.evaluate(() => window.scrollX)).toBe(0)
|
||||
}
|
||||
|
||||
async function expectReviewWidthStable(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
await expect
|
||||
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
const widths = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
|
||||
)
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
}
|
||||
|
||||
async function expectStackedGeometry(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(9)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(7)
|
||||
}
|
||||
|
||||
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((direction) => {
|
||||
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
|
||||
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
|
||||
if (!frame || !content) return Number.POSITIVE_INFINITY
|
||||
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
|
||||
}, direction),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function expectStackPainted(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
|
||||
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalTopMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalTops = []
|
||||
})
|
||||
}
|
||||
|
||||
async function resetTerminalBottomMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalBottoms = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalBottomFixed(page: Page) {
|
||||
const bottoms = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
|
||||
)
|
||||
expect(bottoms.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalAnchorGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.resetAnchorOnMotion = true
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPanelGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.panelGaps = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectPanelGapHeld(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
|
||||
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectTerminalTopAnchored(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
|
||||
}
|
||||
|
||||
async function resetTerminalContentSizes(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalContentSizes = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalContentCachedSize(page: Page) {
|
||||
const sizes = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
|
||||
)
|
||||
expect(sizes.length).toBeGreaterThan(0)
|
||||
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
|
||||
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
|
||||
}
|
||||
|
||||
async function expectTerminalTopMotion(page: Page) {
|
||||
const tops = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
|
||||
)
|
||||
const unique = [...new Set(tops)]
|
||||
const range = Math.max(...unique) - Math.min(...unique)
|
||||
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
|
||||
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
|
||||
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
|
||||
}
|
||||
|
||||
async function expectHeightMotions(page: Page, slot: string, count: number) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(slot) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
|
||||
.length ?? 0,
|
||||
slot,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectAnimation(page: Page, name: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(name) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
|
||||
name,
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,133 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] {
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="true"] {
|
||||
animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="false"] {
|
||||
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
|
||||
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
|
||||
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-in {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-out {
|
||||
from {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -36,7 +36,7 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
|
||||
"md:max-w-[1000px] md:mx-auto": controller.centered(),
|
||||
}}
|
||||
>
|
||||
<Show when={controller.state.questionRequest()} keyed>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SessionSidePanel(props: {
|
||||
fileBrowserState: SessionFileBrowserState
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewSnap: boolean
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
}) {
|
||||
@@ -79,6 +79,7 @@ export function SessionSidePanel(props: {
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
|
||||
const fileOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
@@ -88,11 +89,12 @@ export function SessionSidePanel(props: {
|
||||
}),
|
||||
)
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const visible = createMemo(() => reviewVisible() || fileOpen())
|
||||
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!open()) return "0px"
|
||||
if (reviewOpen()) return "auto"
|
||||
if (!visible()) return "0px"
|
||||
if (reviewVisible()) return "auto"
|
||||
return `${fileTreeWidth()}px`
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
@@ -252,14 +254,14 @@ export function SessionSidePanel(props: {
|
||||
"h-full min-h-0": props.stacked,
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
"flex-1": reviewOpen(),
|
||||
!props.size.active(),
|
||||
"flex-1": reviewVisible(),
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={open()}>
|
||||
<Show when={visible()}>
|
||||
<div class="size-full flex">
|
||||
<Show when={reviewOpen()}>
|
||||
<Show when={reviewVisible()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
|
||||
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
|
||||
<DragDropProvider
|
||||
|
||||
@@ -101,11 +101,11 @@ export const focusTerminalById = (id: string) => {
|
||||
|
||||
const textarea = terminal.querySelector("textarea")
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
textarea.focus()
|
||||
textarea.focus({ preventScroll: true })
|
||||
return true
|
||||
}
|
||||
|
||||
terminal.focus()
|
||||
terminal.focus({ preventScroll: true })
|
||||
terminal.dispatchEvent(
|
||||
typeof PointerEvent === "function"
|
||||
? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
|
||||
|
||||
@@ -124,11 +124,12 @@ export function createSessionReview(input: {
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
|
||||
})
|
||||
}, 100)
|
||||
onCleanup(
|
||||
location().event.listen((event) => {
|
||||
createEffect(() => {
|
||||
const stop = location().event.listen((event) => {
|
||||
if (event.type === "filesystem.changed") refresh()
|
||||
}),
|
||||
)
|
||||
})
|
||||
onCleanup(stop)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => input.screen.review.open() || mobileChanges(),
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel }) {
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
@@ -79,7 +79,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel }) {
|
||||
fileBrowserState={props.review.panelState}
|
||||
activeDiff={props.review.activeFile()}
|
||||
focusReviewDiff={props.review.focusFile}
|
||||
reviewSnap={props.review.screen.review.snap()}
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
/>
|
||||
|
||||
@@ -2,12 +2,11 @@ import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { LocationProvider } from "@/workspaces/location"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { useNotification } from "@/shell/notifications/notification"
|
||||
import { ComposerPersistenceProvider } from "@/composer/persistence"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { TerminalProvider } from "@/session/terminal/context"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
@@ -86,7 +85,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value()}>
|
||||
<LocationProvider directory={value}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
@@ -114,23 +113,18 @@ function SessionStatePanel(props: ParentProps) {
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
|
||||
return (
|
||||
// Keep workspace-scoped file, prompt, comment, and terminal state alive when
|
||||
// the user switches between Sessions in the same workspace.
|
||||
<Show when={`${server.scope}\0${location().directory}`} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
// These providers select their scoped state reactively and retain bounded caches,
|
||||
// so keep their owners alive while navigating between workspaces on this server.
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createComputed, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -14,11 +15,9 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
|
||||
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 sideTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "side")
|
||||
const bottomTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "bottom")
|
||||
const sideTerminalOpen = createMemo(() => terminalOpen() && sideTerminal())
|
||||
const fileTreeOpen = createMemo(
|
||||
() =>
|
||||
session.isDesktop() &&
|
||||
@@ -29,14 +28,14 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
)
|
||||
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
|
||||
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
|
||||
const [rowWidth, setRowWidth] = createSignal<number>()
|
||||
const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({})
|
||||
let row: HTMLDivElement | undefined
|
||||
createResizeObserver(
|
||||
() => row,
|
||||
({ width }) => setRowWidth(width),
|
||||
({ width, height }) => setRowSize({ width, height }),
|
||||
)
|
||||
const available = createMemo<number | undefined>(() => {
|
||||
const width = rowWidth()
|
||||
const width = rowSize.width
|
||||
if (width === undefined) return undefined
|
||||
return width - 8
|
||||
})
|
||||
@@ -65,24 +64,30 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
files: fileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
const [reviewSnap, setReviewSnap] = createSignal(false)
|
||||
let reviewFrame: number | undefined
|
||||
createComputed((previous) => {
|
||||
const open = reviewOpen()
|
||||
if (previous === undefined || previous === open) return open
|
||||
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
setReviewSnap(true)
|
||||
reviewFrame = requestAnimationFrame(() => {
|
||||
reviewFrame = undefined
|
||||
setReviewSnap(false)
|
||||
})
|
||||
return open
|
||||
}, reviewOpen())
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false })
|
||||
createEffect((previous) => {
|
||||
const stacked = panelLayout().stacked
|
||||
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
|
||||
return stacked
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
|
||||
const terminalPane = createMemo(() =>
|
||||
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
)
|
||||
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
|
||||
const sideHeight = createMemo(() => rowSize.height)
|
||||
const fullSideHeight = createMemo(() => (sideHeight() === undefined ? "100%" : `${sideHeight()}px`))
|
||||
const stackedReviewHeight = createMemo(() => {
|
||||
const height = sideHeight()
|
||||
if (height === undefined) return `calc(100% - ${terminalPaneHeight()} - 8px)`
|
||||
return `${Math.max(0, height - terminalPane() - 8)}px`
|
||||
})
|
||||
|
||||
const sideContentWidth = createMemo<string>((previous) => {
|
||||
const width = available()
|
||||
if (resizable() && width !== undefined) return `${Math.max(0, width - resizedWidth())}px`
|
||||
if (fileTreeOpen()) return `${layout.fileTree.width()}px`
|
||||
return previous
|
||||
}, "100%")
|
||||
return {
|
||||
centered: createMemo(() => session.isDesktop()),
|
||||
files: { open: fileTreeOpen },
|
||||
@@ -99,14 +104,36 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
review: {
|
||||
open: reviewOpen,
|
||||
panelOpen: reviewPanelOpen,
|
||||
snap: reviewSnap,
|
||||
},
|
||||
side: { layout: panelLayout },
|
||||
side: {
|
||||
contentWidth: sideContentWidth,
|
||||
gap: {
|
||||
closing: () => motion.closing,
|
||||
height: createMemo(() => (motion.gap ? "8px" : "0px")),
|
||||
},
|
||||
layout: panelLayout,
|
||||
region: {
|
||||
height: createMemo(() => {
|
||||
if (!sideRegionOpen()) return "0px"
|
||||
if (sideTerminalOpen()) return stackedReviewHeight()
|
||||
return fullSideHeight()
|
||||
}),
|
||||
open: sideRegionOpen,
|
||||
},
|
||||
terminal: {
|
||||
contentHeight: createMemo(() => (sideRegionOpen() ? terminalPaneHeight() : fullSideHeight())),
|
||||
height: createMemo(() => {
|
||||
if (!sideTerminalOpen()) return "0px"
|
||||
if (sideRegionOpen()) return terminalPaneHeight()
|
||||
return fullSideHeight()
|
||||
}),
|
||||
},
|
||||
},
|
||||
size,
|
||||
terminal: {
|
||||
bottomOpen: bottomTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
|
||||
bottom: bottomTerminal,
|
||||
open: terminalOpen,
|
||||
side: sideTerminal,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -28,7 +29,42 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const screen = createSessionScreenLayout(session, serverSDK.scope)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({ deferRender: false })
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
bottomTerminalCached: false,
|
||||
sideHeightMotion: false,
|
||||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
sideTerminalPresent: false,
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
bottomTerminal?: HTMLDivElement
|
||||
}>({})
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
|
||||
const sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
})
|
||||
const bottomTerminalPresence = createPresence({
|
||||
show: bottomTerminalVisible,
|
||||
element: () => elements.bottomTerminal ?? null,
|
||||
})
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
if (!sideVisible()) setStore("sideHeightMotion", false)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!isDesktop() || screen.terminal.bottom()) setStore("sideTerminalPresent", false)
|
||||
if (isDesktop() && screen.terminal.side()) setStore("bottomTerminalCached", false)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (screen.side.region.open()) setStore("sideRegionPresent", true)
|
||||
if (screen.review.panelOpen()) setStore("sideReviewPresent", true)
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = session.identity.sessionKey()
|
||||
@@ -75,15 +111,9 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={!messagesReady()}>
|
||||
<SessionIdentityHeader
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
session={session.data.info()}
|
||||
/>
|
||||
<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}
|
||||
@@ -135,13 +165,14 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<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 ref={screen.panel.ref} class="relative 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,
|
||||
"@container relative z-10 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(),
|
||||
!screen.size.active(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
@@ -171,51 +202,125 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</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()}>
|
||||
<Show when={sidePresence.present() || store.sideTerminalPresent}>
|
||||
<div
|
||||
ref={(element) => setElements("side", element)}
|
||||
data-slot="session-side-panel-presence"
|
||||
data-opened={sideVisible()}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
setStore("sideHeightMotion", true)
|
||||
}}
|
||||
classList={{
|
||||
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sidePresence.present(),
|
||||
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible":
|
||||
!sidePresence.present(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-panel-content"
|
||||
class="absolute inset-y-0 start-0 h-full"
|
||||
style={{ width: screen.side.contentWidth() }}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-region"
|
||||
classList={{
|
||||
"min-h-0 shrink-0": screen.side.layout().stacked,
|
||||
"min-h-0 flex-1": !screen.side.layout().stacked,
|
||||
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
>
|
||||
<TerminalPanel stacked={screen.side.layout().stacked} />
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
data-slot="session-side-region-presence"
|
||||
data-opened={screen.side.region.open()}
|
||||
class="absolute inset-0"
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "side-region-presence-out") return
|
||||
if (screen.side.region.open()) return
|
||||
if (sideTerminalVisible()) return
|
||||
setStore("sideRegionPresent", false)
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="absolute inset-x-0 bottom-0 flex flex-col">
|
||||
<div
|
||||
data-slot="session-side-panel-gap"
|
||||
classList={{
|
||||
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"delay-0": !screen.side.gap.closing(),
|
||||
"delay-[200ms]": screen.side.gap.closing(),
|
||||
}}
|
||||
style={{ height: screen.side.gap.height() }}
|
||||
onPointerDown={() => screen.size.start()}
|
||||
>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<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()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-side-terminal-region"
|
||||
classList={{
|
||||
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.terminal.height() }}
|
||||
>
|
||||
<Show when={store.sideTerminalPresent}>
|
||||
<div
|
||||
data-slot="side-terminal-panel-presence"
|
||||
data-opened={sideTerminalVisible()}
|
||||
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
|
||||
<TerminalPanel
|
||||
fill
|
||||
framed={false}
|
||||
present={store.sideTerminalPresent}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={screen.terminal.open() && (!isDesktop() || screen.terminal.bottomOpen())}>
|
||||
<div classList={{ "relative min-h-0 shrink-0": isDesktop() }}>
|
||||
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
data-opened={bottomTerminalVisible()}
|
||||
classList={{
|
||||
hidden: !bottomTerminalPresence.present(),
|
||||
"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()}
|
||||
>
|
||||
<div class="absolute z-10 -top-1 left-0 right-0 h-2" onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
@@ -231,7 +336,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel stacked={isDesktop()} />
|
||||
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Terminal } from "@/session/terminal/terminal"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTerminal } from "@/session/terminal/context"
|
||||
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { terminalTabLabel } from "@/session/terminal/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/session/helpers"
|
||||
@@ -26,7 +26,20 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { TerminalSurface } from "./surface"
|
||||
|
||||
export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
const MAX_CACHED_TERMINAL_WORKSPACES = 20
|
||||
|
||||
type TerminalBinding = ReturnType<ReturnType<typeof useTerminal>["bind"]>
|
||||
type CachedTerminalSurface = {
|
||||
key: string
|
||||
workspace: string
|
||||
pty: LocalPTY
|
||||
ops: TerminalBinding
|
||||
focus: boolean
|
||||
}
|
||||
|
||||
export function TerminalPanel(
|
||||
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
|
||||
) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
@@ -45,18 +58,26 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
autoCreated: undefined as string | undefined,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
surfaces: [] as CachedTerminalSurface[],
|
||||
workspaces: [] as string[],
|
||||
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
const max = () => store.view * 0.6
|
||||
const pane = () => Math.min(height(), max())
|
||||
const stacked = createMemo(() => isDesktop() && !!props.stacked)
|
||||
const panelHeight = createMemo(() =>
|
||||
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
|
||||
const panelHeight = createMemo(() => {
|
||||
if (props.fill) return "100%"
|
||||
if (!opened()) return "0px"
|
||||
if (isDesktop()) return stacked() ? `${pane()}px` : "100%"
|
||||
return `${pane()}px`
|
||||
})
|
||||
const contentHeight = createMemo(
|
||||
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`),
|
||||
)
|
||||
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
|
||||
const present = createMemo(() => opened() || !!props.present)
|
||||
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
|
||||
|
||||
onMount(() => {
|
||||
@@ -68,24 +89,29 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
sync()
|
||||
makeEventListener(window, "resize", sync)
|
||||
if (port) makeEventListener(port, "resize", sync)
|
||||
makeEventListener(document, "focusin", (event) => {
|
||||
if (event.target instanceof Element && event.target.closest("#terminal-panel")) return
|
||||
setStore("surfaces", (surface) => surface.focus, "focus", false)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) {
|
||||
setStore("autoCreated", false)
|
||||
setStore("autoCreated", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
||||
const workspace = workspaceKey()
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated === workspace) return
|
||||
terminal.new()
|
||||
setStore("autoCreated", true)
|
||||
setStore("autoCreated", workspace)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||
() => [workspaceKey(), terminal.all().length] as const,
|
||||
([workspace, count], previous) => {
|
||||
if (!previous || previous[0] !== workspace || previous[1] <= 0 || count !== 0) return
|
||||
if (!opened()) return
|
||||
close()
|
||||
},
|
||||
@@ -97,7 +123,10 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
|
||||
([next, id, requested]) => {
|
||||
if (!next || !id || !requested) return
|
||||
focusTerminalById(id)
|
||||
requestAnimationFrame(() => {
|
||||
if (!opened() || terminal.active() !== id || !terminal.focusRequested(id)) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -136,19 +165,44 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
|
||||
const all = terminal.all
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [workspaceKey(), terminal.ready(), terminal.active(), terminal.all()] as const,
|
||||
([workspace, ready, active, ptys]) => {
|
||||
if (!ready) return
|
||||
|
||||
const ids = new Set(ptys.map((pty) => pty.id))
|
||||
const surfaces = store.surfaces.filter((surface) => surface.workspace !== workspace || ids.has(surface.pty.id))
|
||||
const pty = ptys.find((item) => item.id === active)
|
||||
const key = pty ? `${workspace}\0${pty.id}` : undefined
|
||||
if (pty && key && !surfaces.some((surface) => surface.key === key)) {
|
||||
surfaces.push({ key, workspace, pty, ops: terminal.bind(), focus: terminal.focusRequested(pty.id) })
|
||||
}
|
||||
|
||||
const workspaces = [...store.workspaces.filter((item) => item !== workspace), workspace].slice(
|
||||
-MAX_CACHED_TERMINAL_WORKSPACES,
|
||||
)
|
||||
const keep = new Set(workspaces)
|
||||
setStore({ surfaces: surfaces.filter((surface) => keep.has(surface.workspace)), workspaces })
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||
if (store.recovered[key]) return
|
||||
setStore("recovered", key, true)
|
||||
void clone(id)
|
||||
}
|
||||
|
||||
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||
return String(pty.titleNumber || pty.title || pty.id)
|
||||
}
|
||||
|
||||
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||
setStore("recovered", key, false)
|
||||
trim(id)
|
||||
const index = store.surfaces.findIndex((surface) => surface.key === key)
|
||||
if (!store.surfaces[index]?.focus) return
|
||||
setStore("surfaces", index, "focus", false)
|
||||
if (!opened() || terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
terminal.consumeFocus(id)
|
||||
}
|
||||
|
||||
const handleTerminalDragEnd = () => {
|
||||
@@ -167,6 +221,8 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
}}
|
||||
label={language.t("terminal.title")}
|
||||
opened={opened()}
|
||||
present={present()}
|
||||
framed={props.framed}
|
||||
desktop={isDesktop()}
|
||||
stacked={stacked()}
|
||||
height={panelHeight()}
|
||||
@@ -182,7 +238,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
onCollapse={close}
|
||||
>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
@@ -268,34 +324,35 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
<Show when={all().find((pty) => pty.id === id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={terminal.focusRequested(id)}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
class="!px-[14px]"
|
||||
onConnect={() =>
|
||||
markTerminalConnected(terminalRecoveryKey(pty()), id, (terminalID) =>
|
||||
ops.trim(terminalID),
|
||||
)
|
||||
}
|
||||
onCleanup={(terminal) => ops.update(terminal)}
|
||||
onConnectError={() =>
|
||||
recoverTerminal(terminalRecoveryKey(pty()), id, (terminalID) => ops.clone(terminalID))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${surface.pty.id}`}
|
||||
class="absolute inset-0"
|
||||
classList={{
|
||||
hidden:
|
||||
!present() || surface.workspace !== workspaceKey() || surface.pty.id !== terminal.active(),
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
pty={surface.pty}
|
||||
autoFocus={terminal.focusRequested(surface.pty.id)}
|
||||
onAutoFocus={() => {
|
||||
focusTerminalById(surface.pty.id)
|
||||
terminal.consumeFocus(surface.pty.id)
|
||||
}}
|
||||
class="!px-[14px]"
|
||||
onConnect={() =>
|
||||
markTerminalConnected(surface.key, surface.pty.id, (terminalID) => surface.ops.trim(terminalID))
|
||||
}
|
||||
onCleanup={(terminal) => surface.ops.update(terminal)}
|
||||
onConnectError={() =>
|
||||
recoverTerminal(surface.key, surface.pty.id, (terminalID) => surface.ops.clone(terminalID))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -5,6 +5,8 @@ export function TerminalSurface(
|
||||
props: ParentProps<{
|
||||
label: string
|
||||
opened: boolean
|
||||
present?: boolean
|
||||
framed?: boolean
|
||||
desktop: boolean
|
||||
stacked: boolean
|
||||
height: string
|
||||
@@ -22,6 +24,9 @@ export function TerminalSurface(
|
||||
<aside
|
||||
ref={props.ref}
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
@@ -29,13 +34,12 @@ export function TerminalSurface(
|
||||
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"w-full": !props.desktop || props.stacked,
|
||||
"min-w-0 h-full flex-1": props.desktop && props.opened && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !props.opened,
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop,
|
||||
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!props.desktop && !props.resizing,
|
||||
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
|
||||
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
|
||||
}}
|
||||
style={{ height: props.height }}
|
||||
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
|
||||
<ResizeHandle
|
||||
@@ -50,7 +54,8 @@ export function TerminalSurface(
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
data-slot="terminal-panel-content"
|
||||
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop,
|
||||
"pointer-events-none": !props.opened,
|
||||
|
||||
@@ -347,9 +347,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
t.focus()
|
||||
t.textarea?.focus()
|
||||
setTimeout(() => t.textarea?.focus(), 0)
|
||||
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
|
||||
focus()
|
||||
setTimeout(focus, 0)
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
@@ -499,7 +499,7 @@ function MessageTimelineView(
|
||||
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,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -225,20 +225,23 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.on("filesystem.changed", (event) => {
|
||||
invalidateFromWatcher(event, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
loadFile: (file) => {
|
||||
void load(file, { force: true })
|
||||
},
|
||||
node: tree.node,
|
||||
isDirLoaded: tree.isLoaded,
|
||||
refreshDir: (dir) => {
|
||||
void tree.listDir(dir, { force: true })
|
||||
},
|
||||
createEffect(() => {
|
||||
const stop = sdk().event.on("filesystem.changed", (event) => {
|
||||
invalidateFromWatcher(event, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
loadFile: (file) => {
|
||||
void load(file, { force: true })
|
||||
},
|
||||
node: tree.node,
|
||||
isDirLoaded: tree.isLoaded,
|
||||
refreshDir: (dir) => {
|
||||
void tree.listDir(dir, { force: true })
|
||||
},
|
||||
})
|
||||
})
|
||||
onCleanup(stop)
|
||||
})
|
||||
|
||||
const get = (input: string) => {
|
||||
@@ -266,7 +269,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
withPath(input, (file) => view().setSelectedLines(file, range))
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
viewCache.clear()
|
||||
})
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
data-timeline-row={props.row._tag}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": input.centered?.(),
|
||||
"md:max-w-[1000px] md:mx-auto": input.centered?.(),
|
||||
"pt-3": props.row._tag === "AssistantPart" && props.row.previousAssistantPart,
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user