mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 03:46:22 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8db2c9e8ec | ||
|
|
5518ecca6e | ||
|
|
2df00955cb | ||
|
|
e0deffa083 | ||
|
|
4e97b78d98 | ||
|
|
6bb4b35399 | ||
|
|
0ce1383030 | ||
|
|
010cd6131e | ||
|
|
c2348f8f69 | ||
|
|
71317ec7c9 | ||
|
|
4261fe749d | ||
|
|
9dd7149e75 | ||
|
|
0c0a431c9f | ||
|
|
3368e049d2 | ||
|
|
f0b5da1c11 | ||
|
|
15c525dcfb | ||
|
|
4d12e01824 | ||
|
|
70afbac80c | ||
|
|
1c723c56fa | ||
|
|
181428a2f3 | ||
|
|
9b1891fb7e | ||
|
|
d4bf78b348 | ||
|
|
d4ceffe787 | ||
|
|
872e38055e | ||
|
|
0c1dfa9186 | ||
|
|
8f4d706647 | ||
|
|
929374cdfd | ||
|
|
cfa5ba700e | ||
|
|
45a2ed9a97 | ||
|
|
f6333546f8 | ||
|
|
9e153ce7b3 | ||
|
|
eb357f17cf |
@@ -165,6 +165,10 @@ Native chronological system messages are route/model-specific. Open Responses lo
|
||||
|
||||
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
|
||||
|
||||
### Effort Updates
|
||||
|
||||
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
|
||||
|
||||
### Tools
|
||||
|
||||
Tool loops are represented in common messages and events:
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
|
||||
import { effortUpdate } from "./effort-updates.js"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
@@ -121,9 +122,15 @@ const markMessages = (
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||
if (strategy === "latest-assistant")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let start = messages.length
|
||||
let remaining = strategy.tail
|
||||
while (remaining > 0 && start > 0) {
|
||||
start -= 1
|
||||
if (effortUpdate(messages[start]!) === undefined) remaining -= 1
|
||||
}
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||
for (let i = start; i < messages.length; i++)
|
||||
if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget)
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// A top-level reasoning effort change invalidates the whole provider prompt cache, so a
|
||||
// mid-conversation switch travels as a `Message.effort(...)` marker: protocols with a native
|
||||
// per-message update freeze the top-level effort and lower the markers; every other route strips them.
|
||||
import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js"
|
||||
|
||||
export const effortUpdate = (message: Message): EffortPart | undefined => {
|
||||
if (message.role !== "system" || message.content.length !== 1) return undefined
|
||||
const part = message.content[0]
|
||||
return part.type === "effort" ? part : undefined
|
||||
}
|
||||
|
||||
export const stripEffortUpdates = (request: LLMRequest) => {
|
||||
const messages = request.messages.filter((message) => effortUpdate(message) === undefined)
|
||||
return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages })
|
||||
}
|
||||
|
||||
export const applyEffortUpdates = (request: LLMRequest): LLMRequest =>
|
||||
request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request)
|
||||
|
||||
// The markers must end at `current`: `revert.ts` never touches `session.model` and forks may select
|
||||
// another variant, so on disagreement fall back to a plain top-level change instead of misreporting effort.
|
||||
export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => {
|
||||
const updates = request.messages.flatMap((message) => effortUpdate(message) ?? [])
|
||||
if (updates.length === 0) return { request, effort: current }
|
||||
if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current }
|
||||
return { request, effort: updates[0]?.previous }
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -36,6 +37,7 @@ const ADAPTER = "anthropic-messages"
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
export const DEFAULT_MAX_TOKENS = 32_000
|
||||
const DEFAULT_EFFORT = "high"
|
||||
|
||||
const SSE_EVENTS = new Set([
|
||||
"message",
|
||||
@@ -286,7 +288,11 @@ type AnthropicToolResultBlock = Schema.Schema.Type<typeof AnthropicToolResultBlo
|
||||
const AnthropicMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }),
|
||||
Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }),
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Array(AnthropicTextBlock),
|
||||
output_config: Schema.optional(Schema.Struct({ effort: Schema.String })),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage>
|
||||
|
||||
@@ -877,6 +883,12 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
const update = effortUpdate(message)
|
||||
if (update) {
|
||||
// Accepted at any position, so the text-update placement rules do not apply.
|
||||
messages.push({ role: "system", content: [], output_config: { effort: update.effort ?? DEFAULT_EFFORT } })
|
||||
continue
|
||||
}
|
||||
if (splitsLocalToolResults(request.messages, index))
|
||||
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
|
||||
@@ -1034,18 +1046,11 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const output_config =
|
||||
outputConfigEffort === undefined && outputConfigFormat === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
|
||||
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
|
||||
}
|
||||
const thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
output_config,
|
||||
format: outputConfigFormat,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
@@ -1054,15 +1059,30 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
id.toLowerCase(),
|
||||
)?.groups
|
||||
if (!match) return undefined
|
||||
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
|
||||
}
|
||||
|
||||
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsThinkingBlockBinding
|
||||
if (override !== undefined) return override
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const version = /(?:^|[./])claude-[a-z]+-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups
|
||||
if (!version) return false
|
||||
const major = Number(version.major)
|
||||
const minor = Number(version.minor ?? 0)
|
||||
return major > 5 || (major === 5 && minor >= 1)
|
||||
const version = claudeVersion(model.id)
|
||||
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
|
||||
}
|
||||
|
||||
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
if (version === undefined) return false
|
||||
if (version.family === "opus") return version.major >= 5
|
||||
if (version.family !== "fable" && version.family !== "mythos") return false
|
||||
return version.major > 5 || (version.major === 5 && version.minor >= 1)
|
||||
}
|
||||
|
||||
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
|
||||
@@ -1104,13 +1124,15 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const flattened = ProviderShared.flattenToolRequest(updates.request)
|
||||
const tools =
|
||||
flattened.tools.length === 0
|
||||
? undefined
|
||||
@@ -1138,7 +1160,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
const options = yield* resolveOptions(request)
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1152,7 +1180,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
output_config: options.output_config,
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
container: options.container,
|
||||
@@ -1677,6 +1705,7 @@ export const protocol = Protocol.make({
|
||||
}),
|
||||
step,
|
||||
},
|
||||
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
|
||||
})
|
||||
|
||||
export const transport = <
|
||||
@@ -1715,6 +1744,9 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
|
||||
)
|
||||
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
|
||||
|
||||
if (body.messages.some((message) => message.role === "system" && message.output_config !== undefined))
|
||||
betas.push("mid-conversation-output-config-2026-07-01")
|
||||
|
||||
const thinking = body.thinking
|
||||
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
|
||||
betas.push("thinking-binding-controls-2026-08-01")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate } from "../effort-updates.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -164,6 +165,13 @@ export const CompactionItem = Schema.Struct({
|
||||
encrypted_content: Schema.String,
|
||||
})
|
||||
|
||||
// Kept out of the baseline `InputItem` union: only the OpenAI extension accepts it.
|
||||
export const ConfigurationUpdate = Schema.Struct({
|
||||
type: Schema.Literal("configuration_update"),
|
||||
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
|
||||
})
|
||||
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
CompactionItem,
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
@@ -208,6 +216,7 @@ export type HostedToolReplayItem = {
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| HostedToolReplayItem
|
||||
| ConfigurationUpdate
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -325,9 +334,8 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
@@ -401,6 +409,17 @@ export const Event = Schema.StructWithRest(
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
@@ -624,6 +643,8 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
|
||||
})
|
||||
|
||||
const DEFAULT_EFFORT = "medium"
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
@@ -636,6 +657,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
|
||||
)(message.providerMetadata?.[providerMetadataKey])
|
||||
if (message.role === "system") {
|
||||
const update = effortUpdate(message)
|
||||
if (update) {
|
||||
// Consecutive updates are rejected, so a newer one replaces its predecessor.
|
||||
const last = input.at(-1)
|
||||
if (last !== undefined && "type" in last && last.type === "configuration_update") input.pop()
|
||||
input.push({ type: "configuration_update", reasoning: { effort: update.effort ?? DEFAULT_EFFORT } })
|
||||
continue
|
||||
}
|
||||
input.push({
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
|
||||
@@ -779,8 +808,7 @@ export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(fu
|
||||
}
|
||||
})
|
||||
|
||||
export const lowerGeneration = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
export const lowerGeneration = (request: LLMRequest, options = OpenResponsesOptions.resolve(request)) => {
|
||||
const generation = request.generation
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import { resolveEffortUpdates } from "../effort-updates.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
@@ -94,9 +96,15 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
OpenResponses.InputItem,
|
||||
OpenAIResponsesHostedToolItem,
|
||||
OpenResponses.ConfigurationUpdate,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
@@ -119,7 +127,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
@@ -133,6 +141,14 @@ const adapter = {
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
|
||||
const supportsEffortUpdates = (request: LLMRequest) => {
|
||||
if (request.providerOptions?.contextManagement !== undefined) return false
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
|
||||
}
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
|
||||
@@ -189,10 +205,12 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const updates = resolveEffortUpdates(request, options.reasoningEffort)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
@@ -295,6 +313,7 @@ export const protocol = Protocol.make({
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
supportsEffortUpdates,
|
||||
})
|
||||
|
||||
const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import type { LLMRequest } from "../../schema/index.js"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
|
||||
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
|
||||
(value): value is ReasoningEffort => typeof value === "string",
|
||||
{ title: "ReasoningEffort" },
|
||||
)
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
export const TextVerbosities = ["low", "medium", "high"] as const
|
||||
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
mergeJsonRecords,
|
||||
} from "../../schema/index.js"
|
||||
import type { CompactOperation } from "../../route/client.js"
|
||||
import { stripEffortUpdates } from "../../effort-updates.js"
|
||||
import { Endpoint } from "../../route/endpoint.js"
|
||||
import { RequestExecutor } from "../../route/executor.js"
|
||||
import { HttpTransport } from "../../route/transport/index.js"
|
||||
@@ -75,7 +76,8 @@ const Response = Schema.Struct({
|
||||
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
|
||||
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
|
||||
const route = request.model.route
|
||||
const native = yield* OpenResponses.lowerConversation(request, adapter)
|
||||
// The standalone compaction endpoint rejects histories containing configuration updates.
|
||||
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
|
||||
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
@@ -82,8 +82,13 @@ export const configure = (input: Config) => {
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
|
||||
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -79,9 +79,12 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,8 +39,9 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
@@ -36,8 +36,12 @@ const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
if (!input.baseURL)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
@@ -61,8 +65,13 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
// Read before the exclusivity check narrows a conflicting settings object to `never`.
|
||||
const provider = ProviderID.make(settings.provider ?? id)
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
throw new ProviderConfigurationError({
|
||||
provider,
|
||||
message: "Anthropic-compatible apiKey cannot be combined with authToken",
|
||||
})
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
|
||||
@@ -57,7 +57,10 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Anthropic apiKey cannot be combined with authToken",
|
||||
})
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
@@ -35,7 +35,11 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -28,7 +28,11 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -37,7 +37,8 @@ const route = Route.make({
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -74,7 +75,8 @@ export const provider = {
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
@@ -67,7 +67,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -107,7 +107,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
@@ -39,7 +39,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -79,7 +79,8 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth.js"
|
||||
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
@@ -35,12 +37,18 @@ export const host = (location: string) => {
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex requires a project when baseURL is not configured",
|
||||
})
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
@@ -68,7 +76,10 @@ const adc = (project?: string) => {
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex accessToken cannot be combined with auth",
|
||||
})
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
@@ -93,7 +93,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
})
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
@@ -123,7 +126,10 @@ export const provider = {
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
|
||||
import type { Protocol } from "./protocol.js"
|
||||
import { applyCachePolicy } from "../cache-policy.js"
|
||||
import { applyEffortUpdates } from "../effort-updates.js"
|
||||
import { normalizeToolHistory } from "../tool-history.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
ProviderConfigurationError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -54,6 +56,7 @@ export interface Route<
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
@@ -128,7 +131,10 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
|
||||
})
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
@@ -384,6 +390,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
transport: routeInput.transport,
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
supportsEffortUpdates: protocol.supportsEffortUpdates,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
@@ -554,7 +561,9 @@ const prepareRequest = (request: LLMRequest) => {
|
||||
[...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) =>
|
||||
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
|
||||
)
|
||||
const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) }))
|
||||
const resolved = applyCachePolicy(
|
||||
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
|
||||
)
|
||||
const headers = resolved.model.route.headers?.({ request: resolved })
|
||||
return headers === undefined
|
||||
? resolved
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface Protocol<Body, Frame, Event, State> {
|
||||
readonly body: ProtocolBody<Body>
|
||||
/** Response side: streaming state machine. */
|
||||
readonly stream: ProtocolStream<Frame, Event, State>
|
||||
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
|
||||
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
|
||||
}
|
||||
|
||||
export interface ProtocolBody<Body> {
|
||||
|
||||
@@ -50,6 +50,19 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
|
||||
route: Schema.optional(RouteID),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Provider settings that are missing, conflicting, or unsupported, such as
|
||||
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
|
||||
* provider facade or package entrypoint configures a model, before any
|
||||
* request exists, so it is not an `AIError` reason.
|
||||
*/
|
||||
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
|
||||
"AI.Error.ProviderConfiguration",
|
||||
)("ProviderConfiguration", {
|
||||
provider: ProviderID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LanguageModelSchema,
|
||||
type LanguageModel,
|
||||
ProviderOptions,
|
||||
ReasoningEffort,
|
||||
} from "./options.js"
|
||||
import { ProviderID } from "./ids.js"
|
||||
|
||||
@@ -217,6 +218,14 @@ export const CompactionPart = Object.assign(compactionPartSchema, {
|
||||
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
|
||||
})
|
||||
|
||||
/** Reasoning effort changed here, from `previous` to `effort`; `undefined` is the model default. */
|
||||
export const EffortPart = Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
effort: Schema.optional(ReasoningEffort),
|
||||
previous: Schema.optional(ReasoningEffort),
|
||||
}).annotate({ identifier: "LLM.Content.Effort" })
|
||||
export type EffortPart = Schema.Schema.Type<typeof EffortPart>
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
TextPart,
|
||||
MediaPart,
|
||||
@@ -224,6 +233,7 @@ export const ContentPart = Schema.Union([
|
||||
ToolResultPart,
|
||||
ReasoningPart,
|
||||
CompactionPart,
|
||||
EffortPart,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||
|
||||
@@ -265,6 +275,9 @@ export namespace Message {
|
||||
*/
|
||||
export const system = (content: SystemContentInput) => make({ role: "system", content })
|
||||
|
||||
export const effort = (input: { readonly effort?: ReasoningEffort; readonly previous?: ReasoningEffort }) =>
|
||||
make({ role: "system", content: [{ type: "effort", effort: input.effort, previous: input.previous }] })
|
||||
|
||||
export const tool = (result: ToolResultPart | Parameters<typeof ToolResultPart.make>[0]) =>
|
||||
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
|
||||
}
|
||||
|
||||
@@ -140,6 +140,14 @@ export namespace LanguageModelDefaults {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ordered lowest to highest. */
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
|
||||
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
|
||||
(value): value is ReasoningEffort => typeof value === "string",
|
||||
{ title: "ReasoningEffort" },
|
||||
)
|
||||
|
||||
export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
|
||||
export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
|
||||
|
||||
@@ -165,6 +173,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
|
||||
supportsThinkingBlockBinding: Schema.optional(Schema.Boolean),
|
||||
/** Supports per-message effort updates. Overrides model-ID detection. */
|
||||
supportsEffortUpdates: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelCompatibility {
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart } from "../src/index.js"
|
||||
import { Auth, LLMClient } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
|
||||
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
|
||||
import { Gemini } from "../src/protocols/gemini.js"
|
||||
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
|
||||
import { applyCachePolicy } from "../src/cache-policy.js"
|
||||
import { applyEffortUpdates } from "../src/effort-updates.js"
|
||||
import { it, testEffect } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
const anthropic = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
|
||||
AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id, compatibility })
|
||||
|
||||
const openai = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
|
||||
OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id, compatibility })
|
||||
|
||||
const opus5 = anthropic("claude-opus-5")
|
||||
const astra = openai("gpt-6-astra")
|
||||
|
||||
const lowFromHigh = Message.effort({ effort: "low", previous: "high" })
|
||||
const conversation = [Message.user("Before."), lowFromHigh, Message.user("After.")]
|
||||
|
||||
const systemMessages = (body: AnthropicMessages.AnthropicMessagesBody) =>
|
||||
body.messages.filter((message) => message.role === "system")
|
||||
|
||||
const updates = (body: OpenAIResponses.OpenAIResponsesBody) =>
|
||||
body.input.filter((item) => "type" in item && item.type === "configuration_update")
|
||||
|
||||
describe("applyEffortUpdates", () => {
|
||||
test("keeps the request identity without markers and for protocols that lower them", () => {
|
||||
const plain = LLM.request({ model: opus5, prompt: "hi" })
|
||||
expect(applyEffortUpdates(plain)).toBe(plain)
|
||||
|
||||
const supported = LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } })
|
||||
expect(applyEffortUpdates(supported)).toBe(supported)
|
||||
})
|
||||
|
||||
it.effect("compiles markers away for protocols without per-message effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-3.5-flash" })
|
||||
const withMarkers = yield* compileRequest(LLM.request({ model, messages: conversation }))
|
||||
const withoutMarkers = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.user("Before."), Message.user("After.")] }),
|
||||
)
|
||||
|
||||
expect(withMarkers.body).toEqual(withoutMarkers.body)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("cache policy", () => {
|
||||
it.effect("walks the tail breakpoint back past a trailing effort marker", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus5,
|
||||
messages: [Message.user("first"), Message.assistant("reply"), Message.user("latest"), lowFromHigh],
|
||||
providerOptions: { effort: "low" },
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "first" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "reply" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "latest", cache_control: { type: "ephemeral" } }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Anthropic Messages effort updates", () => {
|
||||
it.effect("lowers markers to per-turn system messages and freezes the top-level effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }),
|
||||
)
|
||||
|
||||
expect(prepared.body.output_config).toEqual({ effort: "high" })
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Before." }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the frozen effort for the model default and sends `high` for a switch back to it", () =>
|
||||
Effect.gen(function* () {
|
||||
const format = { type: "json_schema" as const, schema: { type: "object" } }
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus5,
|
||||
messages: [
|
||||
Message.user("One."),
|
||||
Message.effort({ effort: "low" }),
|
||||
Message.user("Two."),
|
||||
Message.effort({ previous: "low" }),
|
||||
Message.user("Three."),
|
||||
],
|
||||
providerOptions: { output_config: { format } },
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.output_config).toEqual({ format })
|
||||
expect(systemMessages(prepared.body)).toEqual([
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests the mid-conversation output config beta only when markers are sent", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const [model, betas] of [
|
||||
[opus5, "existing-beta,mid-conversation-output-config-2026-07-01"],
|
||||
[anthropic("claude-sonnet-5"), "existing-beta"],
|
||||
] as const) {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
http: { headers: { "anthropic-beta": "existing-beta" } },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(betas)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a marker between a tool call and its result", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus5,
|
||||
messages: [
|
||||
Message.user("Weather?"),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
lowFromHigh,
|
||||
Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 } }),
|
||||
],
|
||||
providerOptions: { effort: "low" },
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const drifted = yield* compileRequest(
|
||||
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }),
|
||||
)
|
||||
const plain = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus5,
|
||||
messages: [Message.user("Before."), Message.user("After.")],
|
||||
providerOptions: { effort: "medium" },
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(drifted.body).toEqual(plain.body)
|
||||
expect(drifted.body.output_config).toEqual({ effort: "medium" })
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [id, supported] of [
|
||||
["claude-opus-5", true],
|
||||
["claude-opus-5-20260901", true],
|
||||
["anthropic/claude-opus-5", true],
|
||||
["claude-fable-5-1", true],
|
||||
["claude-mythos-5-1", true],
|
||||
["claude-fable-5", false],
|
||||
["claude-opus-4-8", false],
|
||||
["claude-sonnet-5", false],
|
||||
["kimi-k2.5", false],
|
||||
] as const) {
|
||||
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }),
|
||||
)
|
||||
|
||||
expect(systemMessages(prepared.body)).toHaveLength(supported ? 1 : 0)
|
||||
expect(prepared.body.output_config).toEqual({ effort: supported ? "high" : "low" })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("honors the compatibility override in both directions", () =>
|
||||
Effect.gen(function* () {
|
||||
const enabled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropic("claude-sonnet-5", { supportsEffortUpdates: true }),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
const disabled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropic("claude-opus-5", { supportsEffortUpdates: false }),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(enabled.body)).toHaveLength(1)
|
||||
expect(systemMessages(disabled.body)).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
|
||||
"claude-opus-5",
|
||||
),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(prepared.body)).toHaveLength(0)
|
||||
expect(prepared.body.output_config).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("OpenAI Responses effort updates", () => {
|
||||
it.effect("lowers markers to configuration_update items and freezes reasoning.effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high" })
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ type: "configuration_update", reasoning: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "input_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces consecutive updates so the newest wins", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: astra,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.effort({ effort: "low", previous: "medium" }),
|
||||
Message.effort({ effort: "xhigh", previous: "low" }),
|
||||
Message.user("After."),
|
||||
],
|
||||
providerOptions: { reasoningEffort: "xhigh" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "medium" })
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ type: "configuration_update", reasoning: { effort: "xhigh" } },
|
||||
{ role: "user", content: [{ type: "input_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits reasoning.effort for the model default and sends `medium` for a switch back to it", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: astra,
|
||||
messages: [
|
||||
Message.user("One."),
|
||||
Message.effort({ effort: "low" }),
|
||||
Message.user("Two."),
|
||||
Message.effort({ previous: "low" }),
|
||||
Message.user("Three."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toBeUndefined()
|
||||
expect(updates(prepared.body)).toEqual([
|
||||
{ type: "configuration_update", reasoning: { effort: "low" } },
|
||||
{ type: "configuration_update", reasoning: { effort: "medium" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const drifted = yield* compileRequest(
|
||||
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }),
|
||||
)
|
||||
const plain = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: astra,
|
||||
messages: [Message.user("Before."), Message.user("After.")],
|
||||
providerOptions: { reasoningEffort: "xhigh" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(drifted.body).toEqual(plain.body)
|
||||
expect(drifted.body.reasoning).toEqual({ effort: "xhigh" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers when automatic context management is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: astra,
|
||||
messages: conversation,
|
||||
providerOptions: { reasoningEffort: "low", contextManagement: [{ type: "compaction" }] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updates(prepared.body)).toEqual([])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "low" })
|
||||
expect(prepared.body.context_management).toEqual([{ type: "compaction" }])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [id, supported] of [
|
||||
["gpt-6-astra", true],
|
||||
["openai/gpt-6-astra", true],
|
||||
["gpt-6-astra-2026-09-01", false],
|
||||
["gpt-5.6-sol", false],
|
||||
] as const) {
|
||||
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }),
|
||||
)
|
||||
|
||||
expect(updates(prepared.body)).toHaveLength(supported ? 1 : 0)
|
||||
expect(prepared.body.reasoning).toEqual({ effort: supported ? "high" : "low" })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("honors the compatibility override in both directions", () =>
|
||||
Effect.gen(function* () {
|
||||
const enabled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: openai("gpt-5.5", { supportsEffortUpdates: true }),
|
||||
messages: conversation,
|
||||
providerOptions: { reasoningEffort: "low" },
|
||||
}),
|
||||
)
|
||||
const disabled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: openai("gpt-6-astra", { supportsEffortUpdates: false }),
|
||||
messages: conversation,
|
||||
providerOptions: { reasoningEffort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updates(enabled.body)).toHaveLength(1)
|
||||
expect(updates(disabled.body)).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const compactRequest = LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-astra"),
|
||||
messages: conversation,
|
||||
providerOptions: { reasoningEffort: "low" },
|
||||
})
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(body.reasoning).toEqual({ effort: "high" })
|
||||
expect(body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ type: "configuration_update", reasoning: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "input_text", text: "After." }] },
|
||||
{ type: "compaction_trigger" },
|
||||
])
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect("keeps configuration updates in the checkpoint body", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(compactRequest, { mechanism: "trigger" })
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
|
||||
expect(JSON.parse(text).input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "After." }] },
|
||||
])
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect("drops markers from the compaction endpoint body", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(compactRequest, { mechanism: "endpoint" })
|
||||
expect(result.replacement.map((message) => message.content[0]?.type)).toEqual(["compaction"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,9 @@ import { model } from "@opencode/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -322,7 +325,7 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
@@ -337,10 +340,10 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
@@ -490,43 +493,45 @@ describe("provider package entrypoints", () => {
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow(
|
||||
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
|
||||
)
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,13 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
|
||||
|
||||
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
|
||||
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
|
||||
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
|
||||
expect(() => Alibaba.configure({ region })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "alibaba",
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
}),
|
||||
)
|
||||
for (const config of [
|
||||
{ baseURL: "https://gateway.example/prefix" },
|
||||
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
|
||||
|
||||
@@ -1458,7 +1458,13 @@ describe("Bedrock Converse route", () => {
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "amazon-bedrock",
|
||||
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
|
||||
@@ -378,7 +378,11 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "google-vertex",
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
{ type: "error" },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
"type": "error",
|
||||
"sequence_number": 4,
|
||||
"code": "server_shutting_down",
|
||||
"message": "Server is shutting down. Please retry your request.",
|
||||
"param": null,
|
||||
"diagnostic": "retain-original-frame"
|
||||
}`
|
||||
for (const model of [
|
||||
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
|
||||
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -19,11 +19,13 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
@@ -43,6 +45,17 @@ story("adding a server keeps all SSH challenges in the original connection dialo
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--incompatible-host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
|
||||
@@ -59,9 +59,8 @@ for (const shared of [true, false]) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await page.keyboard.press("ControlOrMeta+;")
|
||||
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
const toggle = dialog.getByRole("switch")
|
||||
await expect(toggle).not.toBeChecked()
|
||||
@@ -93,7 +92,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -114,10 +113,6 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
|
||||
state.status = "disabled"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/figma-desktop/connect") {
|
||||
state.status = state.fail ? "failed" : "connected"
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
@@ -142,19 +137,12 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
if (surface === "popover") {
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
const panel =
|
||||
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
const toggle = panel.getByRole("switch")
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await expect(toggle).toBeChecked()
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
}
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
@@ -164,7 +152,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
.getByRole("listitem", { includeHidden: true })
|
||||
.filter({ has: page.getByText("Request failed", { exact: true }) })
|
||||
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeChecked({ checked: surface === "popover" })
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
|
||||
@@ -179,18 +167,9 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
|
||||
await expect(toast).toBeHidden()
|
||||
state.fail = false
|
||||
if (surface === "popover") {
|
||||
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
}
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
@@ -104,12 +104,18 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
+4
-5
@@ -2,21 +2,20 @@ import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
@@ -1,452 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
|
||||
|
||||
const directory = "/workspace/summary-project"
|
||||
const workspace = "/workspace/existing-worktree"
|
||||
const createdWorkspace = "/workspace/created-worktree"
|
||||
const draftID = "draft_summary"
|
||||
const secondDraftID = "draft_summary_other"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
|
||||
for (const rtl of [false, true]) {
|
||||
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const mock = await openDraft(page)
|
||||
if (rtl) {
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
}
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
if (!button || !view) return false
|
||||
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
|
||||
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
const project = await summary.locator('[data-section="project"]').boundingBox()
|
||||
const server = await summary.locator('[data-section="server"]').boundingBox()
|
||||
if (!view || !project || !server) return
|
||||
return {
|
||||
top: project.y - view.y - 48,
|
||||
cards: server.y - project.y - project.height,
|
||||
}
|
||||
})
|
||||
.toEqual({ top: 6, cards: 8 })
|
||||
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const [name, item] of [
|
||||
["MCP", "summary-mcp"],
|
||||
["Plugins", "project-plugin"],
|
||||
["Skills", "summary-skill"],
|
||||
["LSP", "typescript"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const menu = await worktreeMenu.boundingBox()
|
||||
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
|
||||
if (!menu || !panel) return false
|
||||
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
|
||||
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
|
||||
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await mcp.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
|
||||
await expect(menu).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
|
||||
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
|
||||
)?.mcp?.states,
|
||||
),
|
||||
)
|
||||
.toEqual({ "summary-mcp": false })
|
||||
|
||||
await page.goto(`/new-session?draftId=${secondDraftID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.goto(draftPath)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "worktree", directory },
|
||||
{ type: "mcp", directory: createdWorkspace, enabled: false },
|
||||
{ type: "session", directory: createdWorkspace },
|
||||
{ type: "prompt", directory: createdWorkspace },
|
||||
])
|
||||
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
})
|
||||
|
||||
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
try {
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "mcp", directory, enabled: false },
|
||||
{ type: "session", directory },
|
||||
{ type: "prompt", directory },
|
||||
])
|
||||
})
|
||||
|
||||
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
mock.state.holdDirectory = directory
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
try {
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.state.fail = true
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
|
||||
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
|
||||
mock.state.fail = false
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(mock.status.get(createdWorkspace)).toBe("disabled")
|
||||
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
|
||||
})
|
||||
|
||||
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.status.set(createdWorkspace, "needs_auth")
|
||||
const attempts: string[] = []
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
return route.fulfill({
|
||||
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: createdWorkspace },
|
||||
data: {
|
||||
id: "summary-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
|
||||
expect(attempts).toEqual([createdWorkspace])
|
||||
expect(mock.prompts).toEqual([])
|
||||
mock.status.set(createdWorkspace, "connected")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main") {
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
name: "summary-project",
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const sessions: ReturnType<typeof currentSession>[] = []
|
||||
const status = new Map<string, string>([
|
||||
[directory, "connected"],
|
||||
[workspace, "disabled"],
|
||||
[createdWorkspace, "connected"],
|
||||
])
|
||||
const calls: { type: string; directory: string; enabled?: boolean }[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "summary-model" },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt(input) {
|
||||
const session = sessions.find((session) => session.id === input.sessionID)
|
||||
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
|
||||
calls.push({ type: "prompt", directory: session.location.directory })
|
||||
prompts.push(input)
|
||||
},
|
||||
})
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
if (route.request().method() === "POST") {
|
||||
const enabled = url.pathname.endsWith("/connect")
|
||||
calls.push({ type: "mcp", directory: target, enabled })
|
||||
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
|
||||
status.set(target, enabled ? "connected" : "disabled")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "summary-mcp",
|
||||
integrationID: "summary-oauth",
|
||||
status: { status: status.get(target) ?? "connected" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/location",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: project.id, directory, canonical: directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
(route) => {
|
||||
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
|
||||
const id = target === directory ? "project-plugin" : "workspace-plugin"
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/skill",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "summary-skill",
|
||||
name: "summary-skill",
|
||||
location: "/skills/summary/SKILL.md",
|
||||
content: "Summary skill",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
calls.push({ type: "worktree", directory })
|
||||
project.sandboxes.push(createdWorkspace)
|
||||
return route.fulfill({ json: { directory: createdWorkspace } })
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
|
||||
calls.push({ type: "session", directory: body.location.directory })
|
||||
const session = currentSession(
|
||||
{ ...body, projectID: project.id, title: "Created summary session" },
|
||||
body.location.directory,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session } })
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
await page.goto(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
|
||||
await expect(
|
||||
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
|
||||
).toBeVisible()
|
||||
return { calls, prompts, status, state }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(status).toBeVisible()
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
@@ -122,11 +122,12 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await details.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
await expect(mcp).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
|
||||
}, theme)
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
new URL(route.request().url()).pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
|
||||
status: { status: "connected" },
|
||||
})),
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
|
||||
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
|
||||
const summary = page.locator('[data-component="session-summary-panel"]')
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch")).toHaveCount(30)
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await menu.boundingBox()
|
||||
return (
|
||||
!!bounds &&
|
||||
bounds.x >= 15 &&
|
||||
bounds.y >= 15 &&
|
||||
bounds.x + bounds.width <= 785 &&
|
||||
bounds.y + bounds.height <= 585
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
|
||||
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
|
||||
})
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { enabled: true }
|
||||
const writes: string[] = []
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (route.request().method() === "POST") {
|
||||
expect(directory).toBe(fixture.directory)
|
||||
writes.push(url.pathname)
|
||||
state.enabled = url.pathname.endsWith("/connect")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
|
||||
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
|
||||
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
|
||||
{ name: "waiting", status: { status: "pending" } },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
|
||||
const row = submenu
|
||||
.locator('[data-component="switch"]')
|
||||
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
|
||||
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
|
||||
"Sign in required",
|
||||
)
|
||||
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
|
||||
const enabled = index % 2 !== 0
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (target === "label") await row.getByText("figma", { exact: true }).click()
|
||||
if (target === "dot") await row.locator(".session-service-dot").click()
|
||||
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
|
||||
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
|
||||
if (target === "keyboard") await toggle.press("Space")
|
||||
await expect(toggle).toBeChecked({ checked: enabled })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(submenu).toBeVisible()
|
||||
if (target === "keyboard") await expect(toggle).toBeFocused()
|
||||
expect(writes).toHaveLength(index + 1)
|
||||
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
|
||||
}
|
||||
})
|
||||
|
||||
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { status: "disabled" }
|
||||
const attempts: string[] = []
|
||||
const resources = Promise.withResolvers<void>()
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.endsWith("/connect")) {
|
||||
state.status = "needs_auth"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(route.request().url())
|
||||
return route.fulfill({
|
||||
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: {
|
||||
id: "linear-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
const refresh = page.waitForRequest(
|
||||
(request) =>
|
||||
state.status === "needs_auth" &&
|
||||
new URL(request.url()).pathname === "/api/mcp/resource" &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
try {
|
||||
const popup = page.waitForEvent("popup")
|
||||
await submenu.getByText("linear", { exact: true }).click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await refresh
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toHaveAccessibleDescription("Sign in required")
|
||||
} finally {
|
||||
resources.resolve()
|
||||
}
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
|
||||
})
|
||||
|
||||
test("multiple desktop connections show the session's server name", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
const current = { type: "http", http: { url: server }, displayName: "Design server" }
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
hidden: {},
|
||||
lastProject: {},
|
||||
recentlyClosed: {},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
const services = [
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
|
||||
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
|
||||
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
|
||||
] as const
|
||||
|
||||
for (const service of services) {
|
||||
for (const empty of [false, true]) {
|
||||
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
const state = { hold: false }
|
||||
const response = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === service.path,
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (state.hold) await response.promise
|
||||
if (service.name === "LSP")
|
||||
return route.fulfill({
|
||||
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
|
||||
})
|
||||
const items =
|
||||
service.name === "MCP"
|
||||
? [{ name: service.item, status: { status: "connected" } }]
|
||||
: service.name === "Plugins"
|
||||
? [
|
||||
{
|
||||
id: service.item,
|
||||
source: { type: "package", target: service.item },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
|
||||
},
|
||||
)
|
||||
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const trigger = summary.getByRole("button", { name: service.name, exact: true })
|
||||
await trigger.click()
|
||||
const menu = page.getByRole("dialog", { name: service.name, exact: true })
|
||||
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
if (empty) {
|
||||
const message = menu.locator(".session-service-empty")
|
||||
await expect(message).toHaveCSS("padding", "8px 12px")
|
||||
await expect(message).toHaveCSS("gap", "8px")
|
||||
await expect(message).toHaveCSS("font-size", "11px")
|
||||
await expect(message).toHaveCSS("line-height", "16px")
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
|
||||
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
|
||||
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
state.hold = true
|
||||
const refresh = page.waitForRequest(
|
||||
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
|
||||
)
|
||||
try {
|
||||
await trigger.click()
|
||||
await refresh
|
||||
await expect(menu).toHaveAttribute("aria-busy", "true")
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu.getByRole("status")).toHaveCount(0)
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(content).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
const response = Promise.withResolvers<void>()
|
||||
const state = { requested: false }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
state.requested = true
|
||||
await response.promise
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
try {
|
||||
await expect.poll(() => state.requested).toBe(true)
|
||||
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(menu.getByRole("status")).toContainText("Loading")
|
||||
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -1,249 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const layout of ["horizontal", "vertical"] as const) {
|
||||
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((layout) => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
appearance: { ...settings.appearance, tabLayout: layout },
|
||||
general: { ...settings.general, showStatus: true },
|
||||
}),
|
||||
)
|
||||
}, layout)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const server = summary.getByRole("button", { name: "Server", exact: true })
|
||||
await expect(project).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
for (const heading of [project, server]) {
|
||||
await expect(heading).toHaveCSS("column-gap", "8px")
|
||||
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
|
||||
}
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await project.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
await server.click()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
|
||||
.toEqual({ projectExpanded: false, serverExpanded: false })
|
||||
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await trigger.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "false")
|
||||
await server.click()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
|
||||
if (direction === "rtl") {
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
}
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await mcp.hover()
|
||||
await expect(submenu).toHaveCount(0)
|
||||
await mcp.click()
|
||||
await expect(submenu.getByText("No MCP servers configured yet", { exact: true })).toBeVisible()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await mcp.boundingBox()
|
||||
const menu = await submenu.boundingBox()
|
||||
if (!row || !menu) return false
|
||||
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(submenu).toBeHidden()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(mcp).toBeFocused()
|
||||
await mcp.press("Enter")
|
||||
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
|
||||
await mcp.click()
|
||||
await expect(submenu).toBeHidden()
|
||||
|
||||
for (const [name, text] of [
|
||||
["Plugins", "No plugins configured yet"],
|
||||
["Skills", "No skills configured yet"],
|
||||
["LSP", "No LSP servers explicitly configured"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
|
||||
await expect(submenu).toBeHidden()
|
||||
}
|
||||
await summary.getByRole("button", { name: "Server", exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
|
||||
for (const reviewOpen of [false, true]) {
|
||||
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const header = await page.locator("[data-session-title]").boundingBox()
|
||||
const panel = await summary.boundingBox()
|
||||
if (!header || !panel) return Infinity
|
||||
return direction === "ltr"
|
||||
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
|
||||
: Math.abs(panel.x - header.x - 12)
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { fail: true, extra: false }
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/plugin**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
|
||||
{
|
||||
id: "supermemory",
|
||||
source: { type: "package", target: "opencode-supermemory" },
|
||||
features: { server: true },
|
||||
state: { status: "active" },
|
||||
},
|
||||
{
|
||||
id: "broken-plugin",
|
||||
source: { type: "local", path: "/broken.ts" },
|
||||
features: { server: true },
|
||||
state: { status: "failed", error: "Plugin failed to activate" },
|
||||
},
|
||||
...(state.extra
|
||||
? [
|
||||
{
|
||||
id: "daytona",
|
||||
source: { type: "package", target: "opencode-daytona" },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/skill**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
|
||||
{
|
||||
id: "review-animations",
|
||||
name: "review-animations",
|
||||
location: "/skills/review/SKILL.md",
|
||||
content: "Review animations",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/config**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
rust: { command: ["rust-analyzer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
|
||||
],
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(plugins.getByRole("alert")).toContainText("Request failed")
|
||||
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
state.fail = false
|
||||
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
|
||||
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
|
||||
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
|
||||
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
state.extra = true
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Skills", exact: true }).click()
|
||||
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
|
||||
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
|
||||
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
|
||||
await expect(plugins).toBeHidden()
|
||||
await summary.getByRole("button", { name: "LSP", exact: true }).click()
|
||||
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
|
||||
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
|
||||
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
|
||||
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
@@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
|
||||
@@ -188,8 +188,16 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -293,7 +301,7 @@ for (const count of [0, 26]) {
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
@@ -326,23 +334,38 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect(status).toHaveCount(0)
|
||||
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -356,9 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,12 @@ import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
worktree: () => string
|
||||
branch: () => string | undefined
|
||||
submitted: () => void
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const route = useSessionKey()
|
||||
const prompt = useComposerState()
|
||||
@@ -51,7 +49,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
const projectDirectory = location().directory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
const id = Session.ID.create()
|
||||
const pending =
|
||||
worktree === "create"
|
||||
@@ -71,18 +68,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const rollback = async () => {
|
||||
if (!pending) return
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
}
|
||||
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
|
||||
await rollback()
|
||||
if (pending) props.mcp.remember(sessionDirectory, mcp)
|
||||
return
|
||||
}
|
||||
|
||||
const created = data.session.create({
|
||||
id,
|
||||
agent: selection.agent,
|
||||
@@ -104,7 +89,10 @@ export function createNewSessionComposerAdapter(props: {
|
||||
},
|
||||
)
|
||||
if (pending && !(await creation).ok) {
|
||||
await rollback()
|
||||
// Keep retries on the worktree that was already created, not another new checkout.
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
return
|
||||
}
|
||||
const afterCreation = async <T>(run: () => Promise<T>) => {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
|
||||
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
|
||||
const tabs = useTabs()
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore<{
|
||||
preparing: boolean
|
||||
pending: Record<string, Promise<boolean> | undefined>
|
||||
}>({ preparing: false, pending: {} })
|
||||
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
|
||||
const target = createMemo(() => key(input.worktree()))
|
||||
const preview = () => input.worktree() === "create"
|
||||
const directory = createMemo(() => {
|
||||
const selected = input.worktree()
|
||||
return selected === "main" || selected === "create" ? location().directory : selected
|
||||
})
|
||||
const states = createMemo(() => {
|
||||
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
|
||||
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
|
||||
})
|
||||
const toggle = useMcpToggle(directory)
|
||||
const controls: McpControls = {
|
||||
get preview() {
|
||||
return preview()
|
||||
},
|
||||
get states() {
|
||||
return states()
|
||||
},
|
||||
get pending() {
|
||||
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
|
||||
},
|
||||
change(name, enabled) {
|
||||
if (controls.pending) return
|
||||
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
|
||||
if (preview()) return
|
||||
const current = directory()
|
||||
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
setStore("pending", current, request)
|
||||
void request.finally(() => setStore("pending", current, undefined))
|
||||
},
|
||||
}
|
||||
|
||||
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
|
||||
const pending = store.pending[directory]
|
||||
if (pending && !(await pending)) return false
|
||||
const entries = Object.entries(states)
|
||||
if (entries.length === 0) return true
|
||||
const catalog = await sdk.api.mcp.list({ location: { directory } })
|
||||
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
|
||||
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
|
||||
const results = await Promise.all(
|
||||
entries
|
||||
.filter(([name, enabled]) => {
|
||||
const server = catalog.data.find((server) => server.name === name)
|
||||
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
|
||||
})
|
||||
.map(([name, enabled]) =>
|
||||
toggle.mutateAsync({ name, enabled, directory }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (results.some((success) => !success)) return false
|
||||
const current = await sdk.api.mcp.list({ location: { directory } })
|
||||
const unresolved = entries.find(([name, enabled]) => {
|
||||
const status = current.data.find((server) => server.name === name)?.status.status
|
||||
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
|
||||
})
|
||||
if (!unresolved) return true
|
||||
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
|
||||
throw new Error(
|
||||
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
|
||||
name: unresolved[0],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
controls,
|
||||
directory,
|
||||
capture: () => ({ ...states() }),
|
||||
remember(directory: string, states: Readonly<Record<string, boolean>>) {
|
||||
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
|
||||
},
|
||||
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
|
||||
if (!store.pending[directory] && !Object.keys(states).length) return true
|
||||
setStore("preparing", true)
|
||||
return apply(directory, states)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.summary.mcp.prepareFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
})
|
||||
.finally(() => setStore("preparing", false))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
|
||||
@@ -1,18 +1,19 @@
|
||||
import { createPromptProjectController } from "@/new-session/project/selector"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, untrack } from "solid-js"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
import { useComposerCommands } from "@/composer/commands"
|
||||
import { createNewSessionComposerAdapter } from "./composer-adapter"
|
||||
import { NewSessionView } from "./view"
|
||||
import { NewSessionStatus, NewSessionView } from "./view"
|
||||
import { createNewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { useNewSessionCommands } from "./commands"
|
||||
import { createDraftMcpControls } from "./mcp"
|
||||
|
||||
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
|
||||
export default function NewSessionPage(props: { draftId: string }) {
|
||||
const settings = useSettings()
|
||||
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const tabs = useTabs()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
@@ -30,13 +31,11 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
|
||||
const composer = createNewSessionComposerAdapter({
|
||||
draftID: props.draftId,
|
||||
worktree: workspace.selection.value,
|
||||
branch: workspace.bar.branch,
|
||||
submitted: workspace.selection.remember,
|
||||
mcp,
|
||||
})
|
||||
const model = createComposerModel(composer.adapter)
|
||||
useComposerCommands({ model: composer.model })
|
||||
@@ -73,8 +72,9 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ProjectSummaryCard } from "@/session/summary/project-card"
|
||||
import { SessionServerPanel } from "@/session/summary/server-panel"
|
||||
import type { PromptProject } from "./project/selector"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { PromptWorkspaceSelector } from "./workspace/selector"
|
||||
|
||||
export function NewSessionSummary(props: {
|
||||
project?: PromptProject
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
shown: boolean
|
||||
onChooseProject: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-component="session-summary-panel">
|
||||
<Show
|
||||
when={props.project}
|
||||
fallback={
|
||||
<div class="session-summary-card">
|
||||
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
|
||||
<Icon name="folder" class="text-v2-icon-icon-muted" />
|
||||
{language.t("session.summary.chooseProject")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(project) => (
|
||||
<>
|
||||
<ProjectSummaryCard project={project()}>
|
||||
<PromptWorkspaceSelector
|
||||
variant="summary"
|
||||
value={props.workspace.selection.value()}
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branches={props.workspace.project.branches()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onChange={props.workspace.selection.set}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
</ProjectSummaryCard>
|
||||
<SessionServerPanel directory={props.mcp.directory()} shown={props.shown} mcp={props.mcp.controls} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
@@ -14,6 +13,8 @@ import {
|
||||
PromptProjectSelector,
|
||||
type PromptProjectController,
|
||||
} from "@/new-session/project/selector"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -22,13 +23,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
import { SummaryPopover } from "@/session/summary/popover"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
const NewSessionSummary = lazy(async () => {
|
||||
const { NewSessionSummary } = await import("./summary")
|
||||
return { default: NewSessionSummary }
|
||||
})
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -46,9 +40,7 @@ export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const [store, setStore] = createStore({ summary: false })
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
WorkspaceOnboardingSchema,
|
||||
@@ -69,25 +61,6 @@ export function NewSessionView(props: {
|
||||
active={props.composer.state.drag === "active"}
|
||||
input={props.composer.model.selection.current()?.capabilities.input}
|
||||
/>
|
||||
<div
|
||||
data-slot="new-session-summary"
|
||||
class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3"
|
||||
>
|
||||
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
|
||||
<Suspense>
|
||||
<NewSessionSummary
|
||||
project={props.project.selected()}
|
||||
workspace={props.workspace}
|
||||
mcp={props.mcp}
|
||||
shown={store.summary}
|
||||
onChooseProject={() => {
|
||||
setStore("summary", false)
|
||||
props.project.add()
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</SummaryPopover>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<NewSessionWordmark />
|
||||
@@ -142,6 +115,19 @@ export function NewSessionView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function NewSessionStatus(props: { visible: boolean }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<TitlebarRight>
|
||||
<Show when={props.visible}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
|
||||
@@ -15,18 +15,13 @@ export function PromptWorkspaceSelector(props: {
|
||||
branches: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
variant?: "inline" | "summary"
|
||||
onChange: (value: string) => void
|
||||
onCreate: (branch: string) => void
|
||||
onSearch: (search: string) => void
|
||||
onDone?: () => void
|
||||
onDone: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const summary = () => props.variant === "summary"
|
||||
const placement = createMemo(() =>
|
||||
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
|
||||
)
|
||||
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
@@ -63,20 +58,17 @@ export function PromptWorkspaceSelector(props: {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone?.()
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main")
|
||||
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={!summary()}>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
</Show>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
@@ -95,28 +87,18 @@ export function PromptWorkspaceSelector(props: {
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class={summary() ? "min-w-0 w-full" : "min-w-0"}
|
||||
class="min-w-0"
|
||||
>
|
||||
<Menu
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={24}
|
||||
modal={summary() ? false : undefined}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
@@ -124,11 +106,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-[200px]">
|
||||
@@ -255,47 +233,22 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Tooltip>
|
||||
<Show
|
||||
when={selected() === "create" && props.branch}
|
||||
fallback={
|
||||
summary() ? (
|
||||
<Show when={props.branch}>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{props.branch}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
) : (
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
||||
)
|
||||
}
|
||||
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
>
|
||||
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
|
||||
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
|
||||
branch: props.branch!,
|
||||
})}
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
</span>
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
|
||||
@@ -6,13 +6,6 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export type McpControls = {
|
||||
readonly preview: boolean
|
||||
readonly states: Readonly<Record<string, boolean>>
|
||||
readonly pending: boolean
|
||||
change: (name: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -24,36 +17,31 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
|
||||
}
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
|
||||
const name = typeof input === "string" ? input : input.name
|
||||
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
|
||||
mutationFn: async (name: string) => {
|
||||
const ref = location()
|
||||
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
|
||||
if (!server || (server.status.status === "pending" && typeof input === "string")) return
|
||||
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
|
||||
if (!enabled) {
|
||||
if (!server || server.status.status === "pending") return
|
||||
if (server.status.status === "connected") {
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
|
||||
}
|
||||
if (enabled && server.status.status !== "needs_auth") {
|
||||
await serverSDK.api.mcp.connect({ server: name, location: ref })
|
||||
}
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
await data.location.mcp.server.sync(ref)
|
||||
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === name)
|
||||
if (enabled && current?.status.status === "needs_auth" && current.integrationID) {
|
||||
const integration = await serverSDK.api.integration.get({ integrationID: current.integrationID, location: ref })
|
||||
} else if (server.status.status === "needs_auth" && server.integrationID) {
|
||||
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
|
||||
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
|
||||
if (!method || method.type !== "oauth") throw new Error(language.t("mcp.auth.interactiveForm", { name }))
|
||||
if (!method || method.type !== "oauth")
|
||||
throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
||||
const attempt = await serverSDK.api.integration.oauth.connect({
|
||||
integrationID: current.integrationID,
|
||||
integrationID: server.integrationID,
|
||||
methodID: method.id,
|
||||
location: ref,
|
||||
})
|
||||
platform.openExternal(attempt.data.url)
|
||||
} else {
|
||||
await serverSDK.api.mcp.connect({ server: name, location: ref })
|
||||
}
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
data.location.mcp.resource.invalidate(ref)
|
||||
await Promise.all([data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
// A successful HTTP response can still leave the MCP connection in a failed state.
|
||||
const status = current?.status
|
||||
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
|
||||
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
|
||||
},
|
||||
onError: (error) =>
|
||||
|
||||
@@ -387,7 +387,6 @@ export const dict = {
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
"mcp.status.disabled": "disabled",
|
||||
"mcp.auth.clickToAuthenticate": "Click to authenticate",
|
||||
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
|
||||
|
||||
"dialog.fork.empty": "No messages to fork from",
|
||||
|
||||
@@ -1386,32 +1385,6 @@ export const dict = {
|
||||
"session.summary.title": "Session details",
|
||||
"session.summary.noBranch": "No branch",
|
||||
"session.summary.basedOn": "Based on {{branch}}",
|
||||
"session.summary.server": "Server",
|
||||
"session.summary.chooseProject": "Choose a project",
|
||||
"session.summary.mcp.onCreation": "Applies when the worktree is created",
|
||||
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
|
||||
"session.summary.mcp.unavailable": "MCP server {{name}} is not available in this worktree.",
|
||||
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
|
||||
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
|
||||
"session.summary.mcp": "MCP",
|
||||
"session.summary.plugins": "Plugins",
|
||||
"session.summary.skills": "Skills",
|
||||
"session.summary.lsp": "LSP",
|
||||
"session.summary.failed": "Failed",
|
||||
"session.summary.retry": "Retry",
|
||||
"session.summary.connecting": "Connecting…",
|
||||
"session.summary.needsAuth": "Sign in required",
|
||||
"session.summary.mcp.empty": "No MCP servers configured yet",
|
||||
"session.summary.mcp.add": "Add servers in opencode.json",
|
||||
"session.summary.plugins.manage": "Manage plugins in opencode.json",
|
||||
"session.summary.plugins.empty": "No plugins configured yet",
|
||||
"session.summary.plugins.add": "Add plugins in opencode.json",
|
||||
"session.summary.skills.manage": "Manage skills in opencode.json",
|
||||
"session.summary.skills.empty": "No skills configured yet",
|
||||
"session.summary.skills.add": "Add skills in opencode.json",
|
||||
"session.summary.lsp.configured": "Configured LSPs",
|
||||
"session.summary.lsp.empty": "No LSP servers explicitly configured",
|
||||
"session.summary.lsp.manage": "Manage LSP in opencode.json",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Failed to create worktree",
|
||||
|
||||
@@ -54,8 +54,12 @@ export function createWebPlatform(version: string) {
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
if (import.meta.env.DEV) {
|
||||
const loopback =
|
||||
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
|
||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
|
||||
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createData } from "@opencode/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
@@ -33,6 +34,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
const notificationCoordinator = createNotificationCoordinator()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -57,7 +59,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
if (existing) return existing
|
||||
const serverCtx = createRoot((dispose) => {
|
||||
serverCtxDisposers.set(key, dispose)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key))
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
|
||||
}, owner)
|
||||
serverCtxs.set(key, serverCtx)
|
||||
return serverCtx
|
||||
@@ -131,6 +133,7 @@ function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -159,7 +162,7 @@ function createServerController(
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
createPermissionAutoApprover({ sdk, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
|
||||
@@ -278,6 +278,7 @@ function Open(props: { initial?: string }) {
|
||||
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
|
||||
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
|
||||
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
|
||||
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
|
||||
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
|
||||
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
|
||||
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
|
||||
|
||||
@@ -120,7 +120,13 @@ export function DialogSsh(props: {
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
|
||||
<Show
|
||||
when={
|
||||
!props.promptOnly &&
|
||||
item()?.stage !== "incompatible" &&
|
||||
(!state.prompted || (!!error() && !prompt()))
|
||||
}
|
||||
>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-target">
|
||||
{language.t("ssh.target")}
|
||||
@@ -160,6 +166,12 @@ export function DialogSsh(props: {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={item()?.stage === "incompatible"}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
|
||||
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={prompt()} keyed>
|
||||
{(prompt) => (
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -195,7 +207,7 @@ export function DialogSsh(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Show when={item()?.stage !== "incompatible" && error()}>
|
||||
{(error) => (
|
||||
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
|
||||
{error()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createUniqueId, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
|
||||
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
|
||||
@@ -56,6 +56,7 @@ export function SessionFileBrowserTab(props: {
|
||||
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
|
||||
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
|
||||
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
})
|
||||
const files = createMemo(() => {
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
|
||||
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
export function SessionHeaderSpacer(props: { visible: boolean }) {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && props.visible}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
<>
|
||||
<TitlebarRight>
|
||||
<Show when={isDesktop() && settings.visibility.status()}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<Show when={isDesktop() && props.reserveReviewToggle}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
return { default: StatusDrawer }
|
||||
})
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
return { default: MobilePanelDrawer }
|
||||
@@ -29,9 +34,11 @@ export function SessionMobileViewTabs(props: {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
menu: false,
|
||||
status: false,
|
||||
statusLoaded: false,
|
||||
details: false,
|
||||
detailsLoaded: false,
|
||||
pending: false,
|
||||
pending: undefined as "status" | "details" | undefined,
|
||||
})
|
||||
createEffect(() => props.onDetailsOpenChange?.(store.details))
|
||||
onCleanup(() => props.onDetailsOpenChange?.(false))
|
||||
@@ -88,18 +95,32 @@ export function SessionMobileViewTabs(props: {
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!store.pending) return
|
||||
event.preventDefault()
|
||||
setStore({ details: true, detailsLoaded: true, pending: false })
|
||||
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
|
||||
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
|
||||
setStore("pending", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
|
||||
<Show when={props.details}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: true, menu: false })}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
|
||||
{language.t("session.summary.title")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
|
||||
{language.t("status.popover.trigger")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.statusLoaded}>
|
||||
<Suspense>
|
||||
<StatusDrawer
|
||||
open={store.status}
|
||||
onOpenChange={(open) => setStore("status", open)}
|
||||
returnFocus={() => trigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={store.detailsLoaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
@@ -41,11 +41,6 @@ const SessionMobileFiles = lazy(async () => {
|
||||
return { default: SessionMobileFiles }
|
||||
})
|
||||
|
||||
const SessionSummaryPanel = lazy(async () => {
|
||||
const { SessionSummaryPanel } = await import("./summary/panel")
|
||||
return { default: SessionSummaryPanel }
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
const server = useServer()
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { useData } from "@opencode/session-ui/context"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export type BackgroundTask = {
|
||||
id: string
|
||||
type: "shell" | "subagent"
|
||||
label: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [store, setStore] = createStore({ open: false })
|
||||
createEffect(() => {
|
||||
if (props.tasks.length > 0) return
|
||||
setStore("open", false)
|
||||
})
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
if (task.type === "shell") return language.t("ui.tool.shell")
|
||||
if (!task.agent) return language.t("ui.tool.agent.default")
|
||||
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={store.open}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={(open) => setStore("open", open)}
|
||||
>
|
||||
<Show when={props.tasks.length > 0}>
|
||||
<Popover.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="session-summary-row"
|
||||
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
|
||||
>
|
||||
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.plural("session.background.tasksRunning", props.tasks.length)}
|
||||
active
|
||||
class="session-summary-label"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</Show>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="session-service-menu"
|
||||
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
|
||||
>
|
||||
<For each={props.tasks.slice(0, 10)}>
|
||||
{(task) => (
|
||||
<Dynamic
|
||||
component={task.type === "subagent" ? "a" : "div"}
|
||||
data-component="session-background-list-item"
|
||||
class="session-service-row"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
|
||||
task.type === "subagent",
|
||||
}}
|
||||
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (task.type !== "subagent" || !data.navigateToSession) return
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
setStore("open", false)
|
||||
data.navigateToSession(task.id)
|
||||
}}
|
||||
>
|
||||
<span class="shrink-0">{taskType(task)}</span>
|
||||
<span class="session-summary-label text-v2-text-text-faint">{task.label}</span>
|
||||
</Dynamic>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { configuredLsps } from "./configured-lsp"
|
||||
|
||||
test("lists configured LSP names with project overrides and no inferred built-ins", () => {
|
||||
expect(
|
||||
configuredLsps([
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
rust: { command: ["rust-analyzer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { disabled: true },
|
||||
eslint: { command: ["vscode-eslint-language-server", "--stdio"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual(["eslint", "rust"])
|
||||
expect(configuredLsps([{ type: "document", info: { lsp: true } }])).toEqual([])
|
||||
})
|
||||
|
||||
test("a later whole-LSP setting clears earlier names", () => {
|
||||
expect(
|
||||
configuredLsps([
|
||||
{ type: "document", info: { lsp: { rust: { command: ["rust-analyzer"] } } } },
|
||||
{ type: "document", info: { lsp: false } },
|
||||
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
|
||||
]),
|
||||
).toEqual(["custom"])
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ConfigEntry } from "@opencode/client"
|
||||
|
||||
export function configuredLsps(entries: readonly ConfigEntry[]) {
|
||||
return entries
|
||||
.reduce<string[]>((names, entry) => {
|
||||
if (entry.type !== "document" || entry.info.lsp === undefined) return names
|
||||
const lsp = entry.info.lsp
|
||||
if (typeof lsp === "boolean") return []
|
||||
return [
|
||||
...names.filter((name) => !Object.hasOwn(lsp, name)),
|
||||
...Object.entries(lsp)
|
||||
.filter(([, server]) => !server.disabled)
|
||||
.map(([name]) => name),
|
||||
]
|
||||
}, [])
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
|
||||
import { BackgroundWorkSummary, type BackgroundTask } from "./background"
|
||||
import { SessionServerPanel } from "./server-panel"
|
||||
import { ProjectSummaryCard } from "./project-card"
|
||||
import "./summary.css"
|
||||
|
||||
export function SessionSummaryPanel(props: {
|
||||
shown?: boolean
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs?: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
backgroundTasks: BackgroundTask[]
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const expanded = settings.sessionSummary.projectExpanded
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
const location = () => {
|
||||
if (props.local) return language.t("session.new.workspace.local")
|
||||
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
|
||||
return getFilename(workspace ?? props.directory)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
|
||||
<div>
|
||||
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
class="session-summary-row"
|
||||
>
|
||||
<Icon name={props.local ? "monitor" : "outline-worktree"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{props.branch}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="session-summary-row" onClick={props.onReview}>
|
||||
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
|
||||
{(diffs) => (
|
||||
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChanges appearance="standard" changes={diffs()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</ProjectSummaryCard>
|
||||
<Show when={expanded() && props.local && props.diffs?.length && props.moveEligible && !props.moveDismissed}>
|
||||
<div class="session-summary-move">
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
class="session-summary-row"
|
||||
>
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class="session-summary-dismiss"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={props.onMoveDismiss}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<SessionServerPanel directory={props.directory} shown={props.shown !== false} mobile={props.mobile} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function SummaryPopover(props: ParentProps<{ open: boolean; onOpenChange: (open: boolean) => void }>) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
|
||||
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={props.open ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={props.open}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
|
||||
aria-label={language.t("session.summary.title")}
|
||||
>
|
||||
{props.children}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import "./summary.css"
|
||||
|
||||
export function ProjectSummaryCard(
|
||||
props: ParentProps<{
|
||||
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
|
||||
avatar?: JSX.Element
|
||||
}>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
const contentID = createUniqueId()
|
||||
const expanded = settings.sessionSummary.projectExpanded
|
||||
return (
|
||||
<section class="session-summary-card" data-section="project">
|
||||
<button
|
||||
type="button"
|
||||
class="session-summary-row session-summary-heading"
|
||||
aria-label={displayName(props.project)}
|
||||
aria-expanded={expanded()}
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
|
||||
>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span class="session-summary-heading-label">
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Index,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { pluginLabel } from "@/providers/catalog/plugin"
|
||||
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
|
||||
import { configuredLsps } from "./configured-lsp"
|
||||
|
||||
const services = [
|
||||
{ type: "mcp", icon: "mcp", label: "session.summary.mcp" },
|
||||
{ type: "plugins", icon: "cube", label: "session.summary.plugins" },
|
||||
{ type: "skills", icon: "post-skill", label: "session.summary.skills" },
|
||||
{ type: "lsp", icon: "code", label: "session.summary.lsp" },
|
||||
] as const
|
||||
|
||||
type Service = (typeof services)[number]["type"]
|
||||
|
||||
type ServiceMenuProps = {
|
||||
service: (typeof services)[number]
|
||||
directory: string
|
||||
shown: boolean
|
||||
open: boolean
|
||||
mobile?: boolean
|
||||
mcp?: McpControls
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const contentID = createUniqueId()
|
||||
const expanded = settings.sessionSummary.serverExpanded
|
||||
const name = createMemo(() => {
|
||||
const servers = global.servers.list()
|
||||
if (servers.length < 2) return language.t("session.summary.server")
|
||||
return serverName(servers.find((connection) => ServerConnection.key(connection) === server.key) ?? server.conn)
|
||||
})
|
||||
const [store, setStore] = createStore<{ submenu?: Service }>({})
|
||||
createEffect(on([() => props.directory, () => props.shown, expanded], () => setStore("submenu", undefined)))
|
||||
|
||||
return (
|
||||
<section class="session-summary-card" data-section="server">
|
||||
<button
|
||||
type="button"
|
||||
class="session-summary-row session-summary-heading"
|
||||
aria-expanded={expanded()}
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
|
||||
>
|
||||
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-heading-label">
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{name()}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded() ? props.directory : undefined} keyed>
|
||||
{(directory) => (
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
<For each={services}>
|
||||
{(service) => (
|
||||
<ServiceMenu
|
||||
service={service}
|
||||
directory={directory}
|
||||
shown={props.shown}
|
||||
open={store.submenu === service.type}
|
||||
mobile={props.mobile}
|
||||
mcp={props.mcp}
|
||||
onOpenChange={(open) => setStore("submenu", open ? service.type : undefined)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceMenu(props: ServiceMenuProps) {
|
||||
if (props.service.type === "mcp") return <McpMenu {...props} />
|
||||
if (props.service.type === "lsp") return <LspMenu {...props} />
|
||||
return <ServiceCatalog {...props} />
|
||||
}
|
||||
|
||||
function LspMenu(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
(directory) => {
|
||||
data.location.config.invalidate({ directory })
|
||||
return data.location.config.sync({ directory })
|
||||
},
|
||||
)
|
||||
const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? []))
|
||||
createEffect(() => {
|
||||
onCleanup(sdk.event.location(props.directory).on("config.updated", () => void refetch()))
|
||||
})
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={data.location.config.list({ directory: props.directory }) !== undefined}
|
||||
empty={names().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={names().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t("session.summary.lsp.empty")}
|
||||
description={language.t("session.summary.lsp.manage")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="session-service-message">{language.t("session.summary.lsp.configured")}</div>
|
||||
<For each={names()}>
|
||||
{(name) => (
|
||||
<div class="session-service-row">
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function McpMenu(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const toggle = useMcpToggle(() => props.directory)
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && ([props.directory, props.mcp?.preview] as const),
|
||||
async ([directory, preview]) => {
|
||||
data.location.mcp.server.invalidate({ directory })
|
||||
await Promise.all([
|
||||
data.location.mcp.server.sync({ directory }),
|
||||
...(preview ? [data.location.config.sync({ directory })] : []),
|
||||
])
|
||||
},
|
||||
)
|
||||
const servers = createMemo(() =>
|
||||
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
const defaults = createMemo(() =>
|
||||
Object.fromEntries(
|
||||
(data.location.config.list({ directory: props.directory }) ?? []).flatMap((entry) =>
|
||||
entry.type === "document"
|
||||
? Object.entries(entry.info.mcp?.servers ?? {}).map(([name, config]) => [name, !config.disabled] as const)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={
|
||||
data.location.mcp.server.list({ directory: props.directory }) !== undefined &&
|
||||
(!props.mcp?.preview || data.location.config.list({ directory: props.directory }) !== undefined)
|
||||
}
|
||||
empty={servers().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={servers().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t("session.summary.mcp.empty")}
|
||||
description={language.t("session.summary.mcp.add")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show when={props.mcp?.preview}>
|
||||
<div class="session-service-message" data-slot="mcp-preview-hint">
|
||||
{language.t("session.summary.mcp.onCreation")}
|
||||
</div>
|
||||
</Show>
|
||||
<Index each={servers()}>
|
||||
{(server) => {
|
||||
const preview = () => props.mcp?.preview === true
|
||||
const enabled = () =>
|
||||
preview()
|
||||
? (props.mcp?.states[server().name] ?? defaults()[server().name] ?? true)
|
||||
: server().status.status !== "disabled"
|
||||
const pending = () =>
|
||||
(props.mcp?.pending ?? toggle.isPending) || (!preview() && server().status.status === "pending")
|
||||
const error = () => {
|
||||
const status = server().status
|
||||
return status.status === "failed" ? status.error : undefined
|
||||
}
|
||||
const label = () => {
|
||||
if (preview()) return undefined
|
||||
const status = server().status.status
|
||||
if (status === "failed") return language.t("session.summary.failed")
|
||||
if (status === "pending") return language.t("session.summary.connecting")
|
||||
if (status === "needs_auth") return language.t("session.summary.needsAuth")
|
||||
return undefined
|
||||
}
|
||||
const change = (value: boolean) => {
|
||||
if (pending()) return
|
||||
if (props.mcp) return props.mcp.change(server().name, value)
|
||||
toggle.mutate({ name: server().name, enabled: value })
|
||||
}
|
||||
return (
|
||||
<Switch
|
||||
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
|
||||
description={preview() ? language.t("session.summary.mcp.onCreation") : label()}
|
||||
checked={enabled()}
|
||||
readOnly={pending()}
|
||||
aria-disabled={pending()}
|
||||
aria-busy={props.mcp?.pending ?? toggle.isPending}
|
||||
onChange={change}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (event.target === event.currentTarget) change(!enabled())
|
||||
}}
|
||||
title={preview() ? server().name : (error() ?? server().name)}
|
||||
>
|
||||
<span
|
||||
class="session-service-dot"
|
||||
data-status={preview() ? undefined : server().status.status}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{server().name}
|
||||
</span>
|
||||
<Show when={label()}>
|
||||
{(status) => (
|
||||
<span class="session-service-status" aria-hidden="true">
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceCatalog(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [items, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
async (directory) => {
|
||||
if (props.service.type === "plugins") {
|
||||
const result = await sdk.api.plugin.list({ location: { directory } })
|
||||
return result.data
|
||||
.filter((plugin) => plugin.source.type !== "builtin")
|
||||
.map((plugin) => ({
|
||||
name: pluginLabel(plugin),
|
||||
status: plugin.state.status,
|
||||
error: plugin.state.status === "failed" ? plugin.state.error : undefined,
|
||||
}))
|
||||
}
|
||||
data.location.skill.invalidate({ directory })
|
||||
await data.location.skill.sync({ directory })
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
const loaded = () => items.state === "ready" || items.state === "refreshing"
|
||||
const list = createMemo(() => {
|
||||
const entries =
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
? (items.latest ?? [])
|
||||
: []
|
||||
: (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
|
||||
name: skill.name,
|
||||
status: "active",
|
||||
error: undefined,
|
||||
}))
|
||||
return entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
createEffect(() => {
|
||||
onCleanup(
|
||||
sdk.event
|
||||
.location(props.directory)
|
||||
.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", () => void refetch()),
|
||||
)
|
||||
})
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={items.loading}
|
||||
ready={
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
: data.location.skill.list({ directory: props.directory }) !== undefined
|
||||
}
|
||||
empty={list().length === 0}
|
||||
error={items.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={list().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
|
||||
)}
|
||||
description={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="session-service-message">
|
||||
{language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.manage" : "session.summary.skills.manage",
|
||||
)}
|
||||
</div>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<div class="session-service-row" title={item.error ?? item.name}>
|
||||
<span class="session-service-dot" data-status={item.status} aria-hidden="true" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{item.name}
|
||||
</span>
|
||||
<Show when={item.status === "failed"}>
|
||||
<span class="session-service-status">{language.t("session.summary.failed")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServicePopover(
|
||||
props: ServiceMenuProps & {
|
||||
loading: boolean
|
||||
ready: boolean
|
||||
empty: boolean
|
||||
error: unknown
|
||||
retry: () => unknown
|
||||
children: JSX.Element
|
||||
},
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
return (
|
||||
<Popover
|
||||
open={props.open}
|
||||
onOpenChange={(open) => {
|
||||
props.onOpenChange(open)
|
||||
if (open && !props.loading) void props.retry()
|
||||
}}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={16}
|
||||
modal={false}
|
||||
>
|
||||
<Popover.Trigger as="button" type="button" class="session-summary-row">
|
||||
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t(props.service.label)}</span>
|
||||
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="session-service-menu"
|
||||
data-service={props.service.type}
|
||||
data-empty={(props.ready && !props.error && props.empty) || undefined}
|
||||
aria-busy={props.loading}
|
||||
aria-label={language.t(props.service.label)}
|
||||
>
|
||||
<Show
|
||||
when={props.ready || !props.loading}
|
||||
fallback={
|
||||
<div class="session-service-message" role="status">
|
||||
{language.t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!props.error}
|
||||
fallback={
|
||||
<div class="session-service-message" role="alert">
|
||||
<p>{language.t("common.requestFailed")}</p>
|
||||
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
|
||||
{language.t("session.summary.retry")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceEmpty(props: { title: string; description: string }) {
|
||||
return (
|
||||
<div class="session-service-empty">
|
||||
<strong>{props.title}</strong>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
[data-component="session-summary-panel"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 32px);
|
||||
|
||||
&[data-mobile] {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.session-summary-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 4px 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] .session-summary-card {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
.session-summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-summary-row,
|
||||
.session-service-row,
|
||||
[data-component="switch"].session-mcp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.session-summary-row:focus-visible,
|
||||
.session-mcp-row:focus-within {
|
||||
outline: none;
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button.session-summary-row:hover,
|
||||
.session-mcp-row:not([data-disabled]):hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.session-summary-row[data-expanded] {
|
||||
background: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
.session-summary-heading {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.session-summary-heading-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-summary-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
}
|
||||
.session-summary-disclosure {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
.session-summary-heading[aria-expanded="false"] .session-summary-disclosure {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.session-summary-heading[aria-expanded="false"]:dir(rtl) .session-summary-disclosure {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.session-service-menu {
|
||||
z-index: 60;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: min(480px, calc(100dvh - 32px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 4px 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
outline: none;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
|
||||
&[data-empty] {
|
||||
width: 200px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
strong {
|
||||
font-weight: 530;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-message {
|
||||
padding: 6px 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.session-service-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-icon-icon-faint);
|
||||
}
|
||||
.session-service-dot[data-status="connected"],
|
||||
.session-service-dot[data-status="active"] {
|
||||
background: var(--icon-success-base);
|
||||
}
|
||||
.session-service-dot[data-status="failed"] {
|
||||
background: var(--icon-critical-base);
|
||||
}
|
||||
.session-service-dot[data-status="needs_auth"] {
|
||||
background: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
.session-service-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
.session-mcp-row[aria-disabled="true"] [data-slot="switch-control"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.session-summary-move {
|
||||
position: relative;
|
||||
padding-top: 6px;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
.session-summary-move > .session-summary-row {
|
||||
padding-inline-end: 36px;
|
||||
}
|
||||
.session-summary-dismiss {
|
||||
position: absolute;
|
||||
inset-inline-end: 8px;
|
||||
bottom: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.session-summary-dismiss:hover,
|
||||
.session-summary-dismiss:focus-visible {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-component="session-summary-panel"] .session-summary-row,
|
||||
.session-service-menu .session-service-row,
|
||||
.session-service-menu .session-mcp-row {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-service-menu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DataProvider } from "@opencode/session-ui/context"
|
||||
import { BackgroundMoveHint } from "./message-timeline"
|
||||
import { BackgroundWorkSummary } from "../summary/background"
|
||||
import "../summary/summary.css"
|
||||
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
|
||||
|
||||
const tasks = [
|
||||
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
Suspense,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import type { SessionUserActions } from "@opencode/session-ui/actions"
|
||||
import { useData } from "@opencode/session-ui/context"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { InlineInput } from "@opencode/ui/inline-input"
|
||||
@@ -21,30 +13,34 @@ import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { SummaryPopover } from "../summary/popover"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Timeline } from "@opencode/session-ui/timeline/projection"
|
||||
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
|
||||
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
|
||||
import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown-cache"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
import type { BackgroundTask } from "../summary/background"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
|
||||
const SessionSummaryPanel = lazy(async () => {
|
||||
const { SessionSummaryPanel } = await import("../summary/panel")
|
||||
return { default: SessionSummaryPanel }
|
||||
})
|
||||
type BackgroundTask = {
|
||||
id: string
|
||||
type: "shell" | "subagent"
|
||||
label: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
type SessionBackground = {
|
||||
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
|
||||
@@ -74,6 +70,283 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
|
||||
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
|
||||
const presence = createAnimatedPresence(
|
||||
() => (props.tasks.length > 0 ? true : undefined),
|
||||
() => triggerRef() ?? null,
|
||||
)
|
||||
createEffect(() => {
|
||||
if (props.tasks.length > 0) return
|
||||
setOpen(false)
|
||||
})
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
if (task.type === "shell") return language.t("ui.tool.shell")
|
||||
if (!task.agent) return language.t("ui.tool.agent.default")
|
||||
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
|
||||
>
|
||||
<Show when={presence.present()}>
|
||||
<Popover.Trigger
|
||||
ref={setTriggerRef}
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
}}
|
||||
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
>
|
||||
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
active
|
||||
class="min-w-0 flex-1 truncate text-start"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</Show>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
|
||||
>
|
||||
<For each={tasks().slice(0, 10)}>
|
||||
{(task) => (
|
||||
<Dynamic
|
||||
component={task.type === "subagent" ? "a" : "div"}
|
||||
data-component="session-background-list-item"
|
||||
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
|
||||
task.type === "subagent",
|
||||
}}
|
||||
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (task.type !== "subagent" || !data.navigateToSession) return
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
data.navigateToSession(task.id)
|
||||
}}
|
||||
>
|
||||
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
|
||||
</Dynamic>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
mobile?: boolean
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const inline = () => props.variant === "inline"
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"group/workspace-move relative shrink-0": true,
|
||||
"ms-auto h-5 w-[167px]": inline(),
|
||||
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
|
||||
!inline(),
|
||||
hidden: props.dismissed,
|
||||
}}
|
||||
>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.eligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={
|
||||
props.mobile
|
||||
? "top-end"
|
||||
: inline()
|
||||
? "bottom-end"
|
||||
: language.direction() === "rtl"
|
||||
? "right-start"
|
||||
: "left-start"
|
||||
}
|
||||
gutter={props.mobile || inline() ? 4 : -22}
|
||||
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
|
||||
inline()
|
||||
? "end-0 top-1/2"
|
||||
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
|
||||
}`}
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDismiss()
|
||||
}}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionSummaryPanel(props: {
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs?: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
backgroundTasks: BackgroundTask[]
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => {
|
||||
if (props.local) return language.t("session.new.workspace.local")
|
||||
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
|
||||
return getFilename(workspace ?? props.directory)
|
||||
}
|
||||
const branch = () => props.branch ?? props.baseBranch
|
||||
const row =
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<Icon
|
||||
name={props.local ? "monitor" : "outline-worktree"}
|
||||
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||
onClick={props.onReview}
|
||||
>
|
||||
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
|
||||
{(diffs) => (
|
||||
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChanges appearance="standard" changes={diffs()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<div
|
||||
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
|
||||
classList={{
|
||||
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
|
||||
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
|
||||
}}
|
||||
>
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
mobile={props.mobile}
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
dismissed={props.moveDismissed}
|
||||
onDismiss={props.onMoveDismiss}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
hideHeader?: boolean
|
||||
active?: boolean
|
||||
@@ -546,32 +819,42 @@ function MessageTimelineView(
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<SummaryPopover open={summaryOpen()} onOpenChange={setSummary}>
|
||||
<Suspense>
|
||||
<SessionSummaryPanel
|
||||
shown={summaryOpen()}
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Suspense>
|
||||
</SummaryPopover>
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<SessionHeaderSpacer visible={props.reserveReviewToggle} />
|
||||
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -382,6 +382,18 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||
|
||||
@@ -34,17 +34,6 @@ describe("settings timeline detail migration", () => {
|
||||
})
|
||||
|
||||
describe("settings schema", () => {
|
||||
test("restores summary expansion and discards the retired status preference", () => {
|
||||
const settings = decode({
|
||||
general: { showStatus: true, showSearch: true },
|
||||
sessionSummary: { projectExpanded: false, serverExpanded: true },
|
||||
})
|
||||
expect(settings.general.showSearch).toBe(true)
|
||||
expect(settings.general).not.toHaveProperty("showStatus")
|
||||
expect(settings.sessionSummary).toEqual({ projectExpanded: false, serverExpanded: true })
|
||||
expect(decode(encode(settings)).sessionSummary).toEqual(settings.sessionSummary)
|
||||
})
|
||||
|
||||
test("uses the supplied initial values independently of the current schema", () => {
|
||||
const initial = {
|
||||
...defaultSettings,
|
||||
@@ -69,6 +58,7 @@ describe("settings schema", () => {
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
@@ -79,7 +69,6 @@ describe("settings schema", () => {
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
sessionSummary: { projectExpanded: true, serverExpanded: true },
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
mono: "",
|
||||
|
||||
@@ -79,6 +79,7 @@ const generalSchema = Persistence.struct({
|
||||
showFileTree: Schema.Boolean,
|
||||
showNavigation: Schema.Boolean,
|
||||
showSearch: Schema.Boolean,
|
||||
showStatus: Schema.Boolean,
|
||||
showProjectIcon: Schema.Boolean,
|
||||
showTerminal: Schema.Boolean,
|
||||
timelineDetail: Persistence.struct({
|
||||
@@ -134,7 +135,6 @@ const soundsSchema = Persistence.struct({
|
||||
|
||||
export const settingsSchema = Persistence.struct({
|
||||
general: generalSchema,
|
||||
sessionSummary: Persistence.struct({ projectExpanded: Schema.Boolean, serverExpanded: Schema.Boolean }),
|
||||
appearance: appearanceSchema,
|
||||
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
|
||||
permissions: permissionsSchema,
|
||||
@@ -243,6 +243,7 @@ export const defaultSettings: Settings = {
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
@@ -253,7 +254,6 @@ export const defaultSettings: Settings = {
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
sessionSummary: { projectExpanded: true, serverExpanded: true },
|
||||
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
|
||||
keybinds: {},
|
||||
permissions: { autoApprove: false },
|
||||
@@ -280,6 +280,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
const showCustomAgents = withFallback(
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
@@ -321,6 +322,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowSearch(value: boolean) {
|
||||
setStore("general", "showSearch", value)
|
||||
},
|
||||
showStatus,
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
|
||||
setShowProjectIcon(value: boolean) {
|
||||
setStore("general", "showProjectIcon", value)
|
||||
@@ -367,25 +372,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "experimentalBrowser", value)
|
||||
},
|
||||
},
|
||||
sessionSummary: {
|
||||
projectExpanded: withFallback(
|
||||
() => store.sessionSummary?.projectExpanded,
|
||||
defaultSettings.sessionSummary.projectExpanded,
|
||||
),
|
||||
serverExpanded: withFallback(
|
||||
() => store.sessionSummary?.serverExpanded,
|
||||
defaultSettings.sessionSummary.serverExpanded,
|
||||
),
|
||||
setProjectExpanded(value: boolean) {
|
||||
setStore("sessionSummary", "projectExpanded", value)
|
||||
},
|
||||
setServerExpanded(value: boolean) {
|
||||
setStore("sessionSummary", "serverExpanded", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
search: showSearch,
|
||||
status: showStatus,
|
||||
customAgents: showCustomAgents,
|
||||
},
|
||||
appearance: {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
const FOCUS_LOCK = "opencode:notification-focus"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function createNotificationCoordinator() {
|
||||
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
|
||||
const claimed = new Set<string>()
|
||||
const focus = { pending: false, release: undefined as (() => void) | undefined }
|
||||
|
||||
const updateFocus = () => {
|
||||
if (typeof document === "undefined" || !document.hasFocus()) {
|
||||
focus.release?.()
|
||||
return
|
||||
}
|
||||
if (!locks || focus.pending || focus.release) return
|
||||
|
||||
focus.pending = true
|
||||
void locks
|
||||
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
|
||||
focus.pending = false
|
||||
if (!document.hasFocus()) return
|
||||
await new Promise<void>((resolve) => {
|
||||
focus.release = resolve
|
||||
})
|
||||
focus.release = undefined
|
||||
})
|
||||
.catch(() => {
|
||||
focus.pending = false
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("focus", updateFocus)
|
||||
window.addEventListener("blur", updateFocus)
|
||||
document.addEventListener("visibilitychange", updateFocus)
|
||||
updateFocus()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("focus", updateFocus)
|
||||
window.removeEventListener("blur", updateFocus)
|
||||
document.removeEventListener("visibilitychange", updateFocus)
|
||||
focus.release?.()
|
||||
})
|
||||
}
|
||||
|
||||
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
|
||||
const key = `${kind}:${eventID}`
|
||||
const execute = async () => {
|
||||
if (!claim(kind, key, claimed)) return
|
||||
await run()
|
||||
}
|
||||
if (!locks) return execute()
|
||||
await locks.request(`opencode:notification:${key}`, execute)
|
||||
}
|
||||
|
||||
return {
|
||||
sound(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("sound", eventID, run)
|
||||
},
|
||||
system(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("system", eventID, async () => {
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return
|
||||
if (!locks) return run()
|
||||
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
|
||||
if (!lock) return
|
||||
await run()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const storageKey = `opencode:notification-${kind}`
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { playSoundByIdOnce } from "@/shell/notifications/sound"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
@@ -114,7 +115,12 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||
export function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
data: Data
|
||||
key: ServerConnection.Key
|
||||
coordinator: ReturnType<typeof createNotificationCoordinator>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -223,7 +229,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
if (session.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
|
||||
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -235,8 +241,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
})
|
||||
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -248,7 +256,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
if (session?.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
|
||||
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -263,8 +271,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,9 +74,6 @@ function getLoads() {
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
const claimed = new Set<string>()
|
||||
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
const loads = getLoads()
|
||||
@@ -103,34 +100,3 @@ export function playSound(src: string | undefined) {
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
||||
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
|
||||
const play = async () => {
|
||||
if (!claim(eventID)) return
|
||||
await playSoundById(id)
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined" || !navigator.locks) return play()
|
||||
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
|
||||
}
|
||||
|
||||
function claim(eventID: string) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useMcpToggle } from "@/providers/connect/mcp"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { pluginLabels } from "@/providers/catalog/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const toggleMcp = useMcpToggle(() => sdk().directory)
|
||||
const mcpServers = createMemo(() =>
|
||||
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-xl"
|
||||
classList={{
|
||||
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
|
||||
"w-full min-w-0": props.embedded,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-active="mcp"
|
||||
defaultValue="mcp"
|
||||
variant="underline"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpServers().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<Index each={mcpServers()}>
|
||||
{(server) => {
|
||||
const name = () => server().name
|
||||
const status = () => server().status.status
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name())
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base": status() === "needs_auth",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-base truncate">{name()}</span>
|
||||
</span>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker truncate">
|
||||
{language.t("mcp.auth.clickToAuthenticate")}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
appearance="standard"
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
|
||||
|
||||
describe("serverStatusDotClass", () => {
|
||||
test("uses the success token while the server and services are healthy", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||
})
|
||||
|
||||
test("uses the session attention token when a service needs attention", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
|
||||
"bg-v2-background-bg-accent",
|
||||
)
|
||||
})
|
||||
|
||||
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||
})
|
||||
|
||||
test("uses the critical token only after the server connection drops", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
|
||||
})
|
||||
|
||||
test("pulses the neutral dot while the event stream is reconnecting", () => {
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
|
||||
"bg-border-weak-base animate-pulse",
|
||||
)
|
||||
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
|
||||
"bg-border-weak-base animate-pulse",
|
||||
)
|
||||
// A server that is known to be down stays critical rather than looking like a routine reconnect.
|
||||
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: true })).toBe(
|
||||
"bg-icon-critical-base",
|
||||
)
|
||||
})
|
||||
|
||||
test("stays neutral before status is ready", () => {
|
||||
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
|
||||
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasNonBlockingServiceIssue", () => {
|
||||
test("detects MCP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
test("detects LSP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasServiceNeedingAttention", () => {
|
||||
test("detects MCP states that need user attention", () => {
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores states that do not need user attention", () => {
|
||||
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
|
||||
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LspStatus } from "@/runtime/server/types"
|
||||
import type { McpServer } from "@opencode/client/promise"
|
||||
|
||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||
return input.mcp.some((status) => status === "needs_auth")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
mcp: Array<McpServer["status"]["status"]>
|
||||
lsp: Array<LspStatus["status"]>
|
||||
}) {
|
||||
return (
|
||||
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||
input.lsp.some((status) => status === "error")
|
||||
)
|
||||
}
|
||||
|
||||
export function serverStatusDotClass(input: {
|
||||
ready: boolean
|
||||
serverHealth: boolean | undefined
|
||||
attention?: boolean
|
||||
issue: boolean
|
||||
connecting?: boolean
|
||||
}) {
|
||||
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
|
||||
// session is visibly waiting on the server rather than silently frozen.
|
||||
if (input.connecting) return "bg-border-weak-base animate-pulse"
|
||||
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||
if (input.attention) return "bg-v2-background-bg-accent"
|
||||
if (input.issue) return "bg-icon-warning-base"
|
||||
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||
return "bg-border-weak-base"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[data-slot="mobile-status-loading"] {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { lazy, Suspense } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { MobilePanelDrawer } from "../mobile-panel-drawer"
|
||||
import "./status-drawer.css"
|
||||
|
||||
const Body = lazy(async () => {
|
||||
const { StatusPopoverBody } = await import("./body")
|
||||
return { default: StatusPopoverBody }
|
||||
})
|
||||
|
||||
export function StatusDrawer(props: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<MobilePanelDrawer
|
||||
title={language.t("status.popover.trigger")}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
returnFocus={props.returnFocus}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div data-slot="mobile-status-loading" role="status">
|
||||
{language.t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Body shown={props.open} embedded />
|
||||
</Suspense>
|
||||
</MobilePanelDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Popover } from "@opencode/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
|
||||
export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const settings = useSettings()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
|
||||
const attention = createMemo(() =>
|
||||
hasServiceNeedingAttention({
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
}),
|
||||
)
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
lsp: [],
|
||||
}),
|
||||
)
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
shown: shown(),
|
||||
ready: ready(),
|
||||
serverHealth: serverHealth(),
|
||||
attention: attention(),
|
||||
issue: issue(),
|
||||
connecting: server.ctx.sdk.connection.status() !== "connected",
|
||||
sidebar: sidebar(),
|
||||
placement: sidebar() ? "top-start" : "bottom-end",
|
||||
shift: sidebar() ? 0 : -168,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<Body shown={shown()} />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
return <StatusPopoverView state={state()} />
|
||||
}
|
||||
|
||||
type StatusPopoverState = {
|
||||
shown: boolean
|
||||
ready: boolean
|
||||
serverHealth: boolean | undefined
|
||||
attention: boolean
|
||||
issue: boolean
|
||||
connecting: boolean
|
||||
sidebar: boolean
|
||||
placement: "top-start" | "bottom-end"
|
||||
shift: number
|
||||
label: string
|
||||
onOpenChange: (value: boolean) => void
|
||||
body: () => JSX.Element
|
||||
}
|
||||
|
||||
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
|
||||
return (
|
||||
<Show when={props.shown}>
|
||||
<Suspense
|
||||
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
|
||||
>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
const popoverProps = {
|
||||
class:
|
||||
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
||||
gutter: 4,
|
||||
placement: props.state.placement,
|
||||
shift: props.state.shift,
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={props.state.sidebar ? "button" : IconButton}
|
||||
triggerProps={
|
||||
props.state.sidebar
|
||||
? {
|
||||
type: "button",
|
||||
class:
|
||||
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
|
||||
"data-state": props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
: {
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}
|
||||
}
|
||||
trigger={
|
||||
<>
|
||||
<div class="relative size-4 shrink-0">
|
||||
<Icon name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
data-slot="status-indicator"
|
||||
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.state.sidebar}>
|
||||
<span class="min-w-0 truncate">{props.state.label}</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
{props.state.body()}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export const Draft = Persistence.struct({
|
||||
directory: Schema.String,
|
||||
worktree: Persistence.optional(Schema.String),
|
||||
branch: Persistence.optional(Schema.String),
|
||||
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
|
||||
})
|
||||
|
||||
const SessionCodec = Session.pipe(
|
||||
|
||||
@@ -16,19 +16,6 @@ function sessionTab(sessionId: string): SessionTab {
|
||||
}
|
||||
|
||||
describe("tab migration", () => {
|
||||
test("round trips draft MCP choices without changing older drafts", () => {
|
||||
const legacy: Tab = { type: "draft", draftID: "legacy-draft", server, directory: "/project" }
|
||||
const draft: Tab = {
|
||||
...legacy,
|
||||
draftID: "mcp-draft",
|
||||
worktree: "create",
|
||||
mcp: { target: "new-worktree", states: { first: true, second: false } },
|
||||
}
|
||||
const restored = decodeTabs([legacy, draft])
|
||||
expect(restored).toEqual([legacy, draft])
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
|
||||
})
|
||||
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
expect(
|
||||
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
|
||||
|
||||
@@ -679,11 +679,12 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={updateState().visible}>
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col gap-2">
|
||||
<TitlebarRightMount vertical />
|
||||
<Show when={updateState().visible}>
|
||||
<TitlebarUpdateIconButton state={updateState()} vertical />
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -76,7 +76,10 @@ export function buildEffortSelectOption(input: {
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(input.currentVariant, input.variants),
|
||||
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
|
||||
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
|
||||
value: variant,
|
||||
name: formatVariantName(variant),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +128,7 @@ export function formatVariantName(variant: string) {
|
||||
}
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
|
||||
if (variant && variants.includes(variant)) return variant
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0] ?? DEFAULT_VARIANT_VALUE
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function streamTurn(input: {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
@@ -455,6 +455,8 @@ async function replayMessage(
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
let reasoningOrdinal = 0
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
await connection.sessionUpdate({
|
||||
@@ -472,7 +474,7 @@ async function replayMessage(
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: message.id,
|
||||
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -41,7 +41,12 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||
import {
|
||||
buildConfigOptions,
|
||||
DEFAULT_VARIANT_VALUE,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
ChildSessionUpdateMethod,
|
||||
@@ -275,7 +280,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog, params.value)
|
||||
const selected = requireModel(state.catalog, params.value, state.model)
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
break
|
||||
@@ -284,7 +289,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const model = state.catalog.models.find(
|
||||
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
|
||||
)
|
||||
if (!model?.variants.some((variant) => variant.id === params.value))
|
||||
if (
|
||||
!model ||
|
||||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
|
||||
)
|
||||
throw new ACPError.InvalidEffortError({ effort: params.value })
|
||||
state.model = { ...state.model, variant: params.value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
@@ -453,7 +461,7 @@ function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function requireModel(catalog: Catalog, modelID: string): ModelRef {
|
||||
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
|
||||
@@ -461,7 +469,14 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
|
||||
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
|
||||
throw new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
return { providerID: model.providerID, id: model.id, variant: selected.variant }
|
||||
const variant =
|
||||
selected.variant ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant }
|
||||
}
|
||||
|
||||
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
|
||||
|
||||
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
|
||||
null,
|
||||
2,
|
||||
)
|
||||
: formatTable(page.data)) + EOL
|
||||
: formatList(page.data)) + EOL
|
||||
const write = Effect.tryPromise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
|
||||
),
|
||||
)
|
||||
|
||||
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
|
||||
const rows = sessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
|
||||
updated: new Date(session.time.updated).toLocaleString(),
|
||||
}))
|
||||
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
|
||||
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
|
||||
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
|
||||
return [
|
||||
header,
|
||||
"─".repeat(header.length),
|
||||
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
|
||||
].join(EOL)
|
||||
function formatList(sessions: ReadonlyArray<SessionInfo>) {
|
||||
return sessions
|
||||
.map((session) =>
|
||||
[
|
||||
session.id,
|
||||
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
|
||||
new Date(session.time.updated).toLocaleString(),
|
||||
].join("\t"),
|
||||
)
|
||||
.join(EOL)
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ describe("acp config option subprocess", () => {
|
||||
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
|
||||
|
||||
expect(effort.category).toBe("thought_level")
|
||||
expect(effort.currentValue).toBe("low")
|
||||
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
|
||||
expect(effort.currentValue).toBe("default")
|
||||
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
|
||||
}, 60_000)
|
||||
|
||||
test("effort switch updates currentValue", async () => {
|
||||
test("effort survives model synchronization and can be reset to default", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
@@ -70,5 +70,23 @@ describe("acp config option subprocess", () => {
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
|
||||
const synchronized = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: requireSelectOption(session.configOptions, "model").currentValue,
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
|
||||
const reset = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("acp event behavior", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves text and reasoning order before returning the terminal response", async () => {
|
||||
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
|
||||
const firstUpdate = Promise.withResolvers<void>()
|
||||
const releaseUpdate = Promise.withResolvers<void>()
|
||||
const allUpdates = Promise.withResolvers<void>()
|
||||
@@ -108,6 +108,14 @@ describe("acp event behavior", () => {
|
||||
delta: "think-1",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: " continued",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_order",
|
||||
@@ -120,7 +128,7 @@ describe("acp event behavior", () => {
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 2,
|
||||
ordinal: 1,
|
||||
delta: "think-2",
|
||||
}),
|
||||
)
|
||||
@@ -144,7 +152,7 @@ describe("acp event behavior", () => {
|
||||
firstUpdate.resolve()
|
||||
await releaseUpdate.promise
|
||||
}
|
||||
if (updates.length === 3) allUpdates.resolve()
|
||||
if (updates.length === 4) allUpdates.resolve()
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
} satisfies Connection
|
||||
@@ -171,18 +179,48 @@ describe("acp event behavior", () => {
|
||||
) {
|
||||
return [
|
||||
item.update.sessionUpdate,
|
||||
item.update.messageId,
|
||||
item.update.content.type === "text" ? item.update.content.text : undefined,
|
||||
]
|
||||
}
|
||||
return [item.update.sessionUpdate, undefined]
|
||||
}),
|
||||
).toEqual([
|
||||
["agent_thought_chunk", "think-1"],
|
||||
["agent_message_chunk", "answer"],
|
||||
["agent_thought_chunk", "think-2"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
|
||||
["agent_message_chunk", "msg_order", "answer"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
|
||||
])
|
||||
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
||||
|
||||
const replayed: SessionUpdateParams[] = []
|
||||
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
|
||||
{
|
||||
id: "msg_order",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 1 },
|
||||
content: [
|
||||
{ type: "reasoning", text: "think-1 continued" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "think-2" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replayed).toEqual([
|
||||
{
|
||||
sessionId: "ses_order",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "msg_order:reasoning:0",
|
||||
content: { type: "text", text: "think-1 continued" },
|
||||
},
|
||||
},
|
||||
updates[2],
|
||||
updates[3],
|
||||
])
|
||||
} finally {
|
||||
releaseUpdate.resolve()
|
||||
releaseSubmit.resolve()
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("acp service directory behavior", () => {
|
||||
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
expect(currentValue(selectedModel, "effort")).toBe("low")
|
||||
expect(currentValue(selectedModel, "effort")).toBe("default")
|
||||
expect(currentValue(selectedEffort, "effort")).toBe("medium")
|
||||
expect(currentValue(selectedMode, "mode")).toBe("plan")
|
||||
expect(
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("acp service lifecycle", () => {
|
||||
model: { providerID: "test", id: "second-model" },
|
||||
},
|
||||
})
|
||||
expect(currentValue(created, "effort")).toBe("none")
|
||||
expect(currentValue(created, "effort")).toBe("default")
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { RelativePath } from "@opencode/schema/schema"
|
||||
import type { Brand } from "effect"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { DateTime } from "effect"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
@@ -26,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
|
||||
import type { Form } from "@opencode/schema/form"
|
||||
import type { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Credential } from "@opencode/schema/credential"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { PermissionSaved } from "@opencode/schema/permission-saved"
|
||||
import type { FileSystem } from "@opencode/schema/filesystem"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
@@ -209,6 +209,7 @@ export type SessionCreateInput = {
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
}
|
||||
export type SessionCreateOutput = Session.Info
|
||||
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
|
||||
@@ -437,6 +438,7 @@ export type SessionLogOutput =
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
readonly version: string
|
||||
}
|
||||
}
|
||||
@@ -489,6 +491,15 @@ export type SessionLogOutput =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.permissions.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -1585,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
|
||||
input: PermissionReplyInput,
|
||||
) => Effect.Effect<PermissionReplyOutput, E>
|
||||
|
||||
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
|
||||
export type PermissionRulesOutput = void
|
||||
export type PermissionRulesOperation<E = never> = (
|
||||
input: PermissionRulesInput,
|
||||
) => Effect.Effect<PermissionRulesOutput, E>
|
||||
|
||||
export interface PermissionApi<E = never> {
|
||||
readonly request: { readonly list: PermissionRequestListOperation<E> }
|
||||
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
|
||||
@@ -1592,6 +1609,7 @@ export interface PermissionApi<E = never> {
|
||||
readonly list: PermissionListOperation<E>
|
||||
readonly get: PermissionGetOperation<E>
|
||||
readonly reply: PermissionReplyOperation<E>
|
||||
readonly rules: PermissionRulesOperation<E>
|
||||
}
|
||||
|
||||
export type FileListInput = {
|
||||
|
||||
@@ -181,6 +181,8 @@ import type {
|
||||
PermissionGetOutput,
|
||||
PermissionReplyInput,
|
||||
PermissionReplyOutput,
|
||||
PermissionRulesInput,
|
||||
PermissionRulesOutput,
|
||||
FileListInput,
|
||||
FileListOutput,
|
||||
FileFindInput,
|
||||
@@ -395,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
permissions: input?.["permissions"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -1145,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
|
||||
preserveEffect<PermissionRulesOutput>()(
|
||||
raw["session.permission.rules"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { permissions: input["permissions"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
|
||||
request: { list: EndpointPermissionRequestList(raw) },
|
||||
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
|
||||
@@ -1152,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
|
||||
list: EndpointPermissionList(raw),
|
||||
get: EndpointPermissionGet(raw),
|
||||
reply: EndpointPermissionReply(raw),
|
||||
rules: EndpointPermissionRules(raw),
|
||||
})
|
||||
|
||||
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
|
||||
|
||||
@@ -175,6 +175,8 @@ import type {
|
||||
PermissionGetOutput,
|
||||
PermissionReplyInput,
|
||||
PermissionReplyOutput,
|
||||
PermissionRulesInput,
|
||||
PermissionRulesOutput,
|
||||
FileReadInput,
|
||||
FileReadOutput,
|
||||
FileListInput,
|
||||
@@ -565,6 +567,7 @@ export function make(options: ClientOptions) {
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
permissions: input?.["permissions"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1566,6 +1569,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionRulesOutput>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
|
||||
body: { permissions: input["permissions"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
file: {
|
||||
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -551,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
|
||||
|
||||
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1651,24 +1629,6 @@ export type SessionInboxMove = {
|
||||
delivery: SessionInboxDelivery
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
export type SessionRevertStaged = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1912,6 +1872,58 @@ export type AgentInfo = {
|
||||
permissions: PermissionRuleset
|
||||
}
|
||||
|
||||
export type SessionPermissionsUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.permissions.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; permissions: PermissionRuleset }
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
permissions?: PermissionRuleset
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
permissions?: PermissionRuleset
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigEntry =
|
||||
| {
|
||||
type: "document"
|
||||
@@ -2084,8 +2096,6 @@ export type ConfigEntry =
|
||||
| { type: "agents"; path: string }
|
||||
| { type: "claude"; path: string }
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionInboxUser = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -2140,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type FormFields2 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
|
||||
|
||||
export type SessionInboxEnqueued = {
|
||||
@@ -2233,6 +2245,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionPermissionsUpdated
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2292,6 +2305,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionPermissionsUpdated
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
@@ -2804,6 +2818,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly id?: string | null
|
||||
@@ -2812,6 +2831,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["title"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
@@ -2820,6 +2844,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
@@ -2828,6 +2857,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
@@ -2836,6 +2870,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["location"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
@@ -2844,7 +2883,25 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["permissions"]
|
||||
}
|
||||
|
||||
export type SessionCreateOutput = { data: SessionInfo }["data"]
|
||||
@@ -2882,6 +2939,11 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3187,6 +3249,11 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3492,6 +3559,11 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -5753,6 +5825,19 @@ export type PermissionReplyInput = {
|
||||
|
||||
export type PermissionReplyOutput = void
|
||||
|
||||
export type PermissionRulesInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly permissions: {
|
||||
readonly permissions: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
}["permissions"]
|
||||
}
|
||||
|
||||
export type PermissionRulesOutput = void
|
||||
|
||||
export type FileReadInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.permissions.updated":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
|
||||
return
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { coerceToString } from "./value.js"
|
||||
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
|
||||
const base64 = (name: "atob" | "btoa") =>
|
||||
sync(name, (args, node) => {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
|
||||
}
|
||||
if (args.length === 0)
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
|
||||
const input = coerceToString(args[0])
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
|
||||
@@ -547,6 +547,14 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
]
|
||||
case "tool-result":
|
||||
return toolResultPart(part)
|
||||
case "effort":
|
||||
throw ProviderShared.unsupportedContent("AI SDK", "assistant", [
|
||||
"text",
|
||||
"media",
|
||||
"reasoning",
|
||||
"tool-call",
|
||||
"tool-result",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user