Compare commits

..
Author SHA1 Message Date
Kit Langton e8c895a940 fix(core): normalize RPC handler failures 2026-09-02 21:14:38 -04:00
74 changed files with 1279 additions and 2562 deletions
+86 -84
View File
@@ -304,11 +304,6 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -321,7 +316,6 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
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
@@ -401,7 +395,6 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -423,13 +416,14 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -881,6 +875,9 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -924,34 +921,9 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -1025,7 +997,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1038,18 +1010,18 @@ const reasoningMetadata = (state: ParserState, item: OutputItem) =>
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
@@ -1066,14 +1038,14 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
},
events,
]
}
if (item.type === "reasoning") {
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1093,16 +1065,18 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
events,
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1174,13 +1148,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: NormalizedEvent["item"],
item: Event["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1197,7 +1171,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message") {
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
@@ -1230,23 +1204,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered || finished.length === 0
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1257,13 +1244,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, item.id]),
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
}
if (item.type === "reasoning") {
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1347,17 +1334,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (
item.type === "function_call" &&
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
)
continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1424,9 +1415,12 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1466,16 +1460,20 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item?.type === "reasoning" &&
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
@@ -1485,7 +1483,11 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1535,7 +1537,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
step,
terminal,
},
})
@@ -201,11 +201,12 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -3,7 +3,8 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.OutputItem & {
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -26,8 +27,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const onDone: (
state: OpenResponses.ParserState,
+1 -2
View File
@@ -69,8 +69,7 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+5 -8
View File
@@ -125,15 +125,12 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("mints an id for terminal checkpoints that omit one", () =>
).effect("rejects terminal checkpoints missing an id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
}),
)
@@ -329,126 +329,69 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
},
])
}),
)
])
}),
)
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool once", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -493,6 +436,21 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -542,15 +500,14 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,21 +586,23 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -608,6 +610,43 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a streamed tool call with only the new tool output", () =>
it.effect("continues an item-id-less tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,7 +485,6 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2130,6 +2129,47 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2349,7 +2389,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2891,10 +2931,14 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
// A completed item that is re-added stays closed.
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3749,6 +3793,43 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3936,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3951,7 +4032,6 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -3973,6 +4053,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -11,7 +11,6 @@ import type {
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -123,7 +122,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean | TimelineDetail>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -23,7 +23,6 @@ for (const direction of ["ltr", "rtl"] as const) {
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
const review = header.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
@@ -32,40 +31,22 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
if (editing) {
await header.getByRole("heading").click()
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
await expect(header.getByRole("textbox")).toBeFocused()
}
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
await expect
.poll(async () => {
const boxes = await Promise.all(
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
control.boundingBox(),
),
)
const [icon, title, menu, sidebar, summary] = boxes
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
}
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await expect
.poll(async () => {
const boxes = await Promise.all(
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
)
const [title, menu, sidebar, summary] = boxes
if (!title || !menu || !sidebar || !summary) return false
return direction === "ltr"
? Math.abs(title.x + title.width - menu.x) <= 1 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "true")
@@ -74,52 +55,6 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toHaveAttribute("aria-expanded", "false")
await more.click()
const options = page.getByRole("menu")
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
if (direction === "ltr") {
await expect
.poll(async () => {
const [button, menu] = await Promise.all([
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
options.boundingBox(),
])
return button && menu ? Math.abs(button.x - menu.x) : Infinity
})
.toBeLessThanOrEqual(1)
}
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const rtl = getComputedStyle(element).direction === "rtl"
return Math.min(
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
return rtl ? text.left - menu.left : menu.right - text.right
}),
)
}),
)
.toBeCloseTo(32, 0)
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
return (
!!divider &&
Math.abs(divider.left - menu.left) <= 0.5 &&
Math.abs(divider.right - menu.right) <= 0.5 &&
rows.every(
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
)
)
}),
)
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
@@ -67,12 +67,6 @@ test("follows a live session move while the agent catalog is still loading", asy
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
)
})
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
@@ -101,7 +95,9 @@ test("follows a live session move while the agent catalog is still loading", asy
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.send({
@@ -225,8 +221,7 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
requests.push(path)
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
})
return requests
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
@@ -17,9 +16,6 @@ for (const width of [1400, 390]) {
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
@@ -1,250 +0,0 @@
import { expect, test } from "@playwright/test"
import { dict } from "../../src/runtime/i18n/ar"
import en from "../../src/runtime/i18n/en"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ serviceWorkers: "block" })
for (const direction of ["ltr", "rtl"] as const) {
for (const workspace of [false, true]) {
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
const copy = direction === "rtl" ? dict : en
const directory = workspace
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
: fixture.directory
const project = {
...fixture.project,
name: workspace
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
: "Timeline project",
sandboxes: workspace ? [directory] : [],
icon: {
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
},
}
await mockOpenCodeServer(page, {
directory,
project,
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem(
"opencode.global.dat:language",
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
)
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
)
}, direction)
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const trigger = header.getByRole("button", { name: project.name, exact: true })
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
await expect(trigger).not.toHaveCSS("background-color", background)
await expect(page.getByRole("tooltip")).toHaveText(project.name)
await trigger.click()
const menu = page.getByRole("menu", { name: project.name, exact: true })
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("tooltip")).toBeHidden()
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
await expect(menu).toHaveCSS("direction", direction)
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await expect(settings).toBeEnabled()
await expect
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(320)
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
await expect(label).toHaveCSS("text-overflow", "ellipsis")
await expect(label).toHaveCSS("white-space", "nowrap")
if (workspace) {
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
}
}
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const icons = menu.locator(
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
)
await expect(icons).toHaveCount(3)
await expect
.poll(async () => {
const [button, centers] = await Promise.all([
trigger.boundingBox(),
icons.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect()
return box.x + box.width / 2
}),
),
])
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
})
.toBe(true)
if (!workspace) await page.clock.install()
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
const item = menu.getByRole("menuitem", { name: text, exact: true })
const anchor = item.locator("..")
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
const content = item.locator(".session-project-link-content")
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
await anchor.hover()
await expect(content).toHaveCSS("mask-image", "none")
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
if (text === directory) {
await expect(openIcon).toHaveCSS("opacity", "0")
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
await expect
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect
.poll(async () => {
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
if (!row || !icon) return false
return (
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
)
})
.toBe(true)
}
await expect(label).toHaveCSS("cursor", "default")
await expect(anchor).toHaveCSS("cursor", "default")
const tooltip = page.getByRole("tooltip")
if (workspace) {
await expect(tooltip).toHaveText(text)
await expect(tooltip).toHaveCSS("white-space", "normal")
await expect
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(480)
await expect
.poll(() =>
tooltip
.getByText(text, { exact: true })
.evaluate(
(element) =>
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
),
)
.toBe(true)
await expect
.poll(async () => {
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
})
.toBe(true)
}
if (!workspace) {
await page.clock.runFor(500)
await expect(tooltip).toBeHidden()
}
await settings.hover()
await expect(tooltip).toBeHidden()
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
await expect(content).toHaveCSS("mask-image", "none")
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(settings).toBeFocused()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
for (const selected of [false, true]) {
if (selected) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
}
await trigger.click()
await expect(projectItem).toBeEnabled()
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
await projectItem.hover()
await expect(projectItem).not.toHaveCSS("background-color", background)
await projectItem.click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
await expect(menu).toBeHidden()
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
}
})
}
}
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.route(
(url) => url.pathname === stressSessionHref(fixture.targetID),
async (route) => {
const response = await route.fetch()
await route.fulfill({
response,
body: (await response.text()).replace(
'<div id="root"',
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
),
})
},
)
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
const arrow = path.locator('[data-slot="session-project-open-icon"]')
await expect(arrow).toHaveCount(1)
await expect
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
})
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -37,9 +36,7 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -71,9 +68,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -105,7 +100,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() =>
row.evaluate((element) => {
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -24,23 +23,20 @@ for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
await timeline.send(status("idle"))
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -50,9 +46,6 @@ test("transitions a streaming shell from writing through command execution", asy
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
@@ -102,9 +95,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -119,7 +110,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -127,13 +118,7 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -156,11 +141,28 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
await expect(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
}
@@ -170,9 +172,6 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
compactionDelta,
compactionEnded,
@@ -50,9 +49,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
@@ -88,12 +84,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
@@ -170,12 +161,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
@@ -212,9 +198,6 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
@@ -230,7 +213,6 @@ for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -299,7 +281,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -388,13 +369,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
@@ -402,7 +376,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -15,9 +14,7 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage([
@@ -37,7 +34,6 @@ test.describe("session timeline projection", () => {
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
@@ -114,11 +110,8 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -134,7 +127,6 @@ test.describe("session timeline projection", () => {
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
sessionMessages: [
{
id: "msg_model_fast_nano",
@@ -5,49 +5,135 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
const settings = page.getByTestId("settings-screen")
await page.keyboard.press("Control+,")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
)
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
}
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
messages: [
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"))
await timeline.send(status("idle"))
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -138,7 +138,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
test("keeps failed search calls and their error cards outside the collapsed stack", async ({ page }) => {
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
"prt_error_glob",
@@ -161,9 +161,12 @@ test("keeps failed search calls and their error cards outside the collapsed stac
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = page.locator('[data-timeline-part-id="prt_error_glob"]')
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
@@ -177,7 +180,7 @@ test("keeps failed search calls and their error cards outside the collapsed stac
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await expect(page.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
})
@@ -1,5 +1,4 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -75,29 +74,21 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
await setupTimeline(page, {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update src/outline.ts" },
{ metadata: { files: [file] } },
),
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -115,6 +106,8 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -12,9 +11,7 @@ test("transitions shell and question through running error outcomes", async ({ p
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
},
settings: { shellToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -27,17 +24,18 @@ test("transitions shell and question through running error outcomes", async ({ p
],
})
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
@@ -48,9 +46,7 @@ test("preserves surviving grouped patch state when its first patch fails", async
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -84,7 +80,6 @@ test("preserves surviving grouped patch state when its first patch fails", async
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
@@ -121,7 +116,6 @@ test("preserves surviving grouped patch state when its first patch fails", async
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
messages: [
userMessage(),
assistantMessage([
@@ -154,10 +148,7 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -20,9 +19,6 @@ for (const width of [1400, 390]) {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
@@ -55,9 +51,7 @@ for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -95,8 +89,13 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
await expect(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
@@ -260,7 +259,6 @@ for (const failed of [false, true]) {
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

-36
View File
@@ -931,7 +931,6 @@ export const dict = {
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
@@ -952,41 +951,6 @@ export const dict = {
"settings.general.section.feed": "Feed",
"settings.general.section.display": "Display",
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.timeline.running": "Running",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
+2
View File
@@ -230,6 +230,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
@@ -1,51 +0,0 @@
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
[data-popper-positioner]:has(.session-project-info-tooltip),
.session-project-info-tooltip {
pointer-events: none !important;
}
.session-options-menu [data-component="menu-v2-item"] {
padding-inline-end: 30px;
}
.session-project-link {
--session-project-fade-direction: to right;
position: relative;
&:dir(rtl) {
--session-project-fade-direction: to left;
}
}
.session-project-link-content {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
}
.session-project-link-open {
position: absolute;
inset-inline-end: 12px;
inset-block: 0;
display: flex;
align-items: center;
color: var(--v2-icon-icon-muted);
pointer-events: none;
opacity: 0;
}
.session-project-link:is(:hover, [data-highlighted]):not([data-disabled]) {
.session-project-link-content {
mask-image: linear-gradient(
var(--session-project-fade-direction),
#000 calc(100% - 40px),
transparent calc(100% - 24px)
);
}
.session-project-link-open {
opacity: 1;
}
}
@@ -1,27 +1,18 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
import "./session-identity-header.css"
export function SessionTitleHeader(props: ParentProps) {
return (
@@ -34,147 +25,6 @@ export function SessionTitleHeader(props: ParentProps) {
)
}
export function SessionProjectMenu(props: {
project?: LocalProject
directory?: string
workspace: boolean
showProjectIcon: boolean
}) {
const server = useServer()
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const layout = useLayout()
const navigate = useNavigate()
const [state, setState] = createStore({ projectTruncated: false, pathTruncated: false })
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
const canOpenPath = () =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
const openPath = () => {
if (!canOpenPath() || !platform.openPath || !props.directory) return
void platform.openPath(props.directory).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
const openProjectSettings = async () => {
const current = props.project
if (!current) return
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
dialog.push(() => <DialogEditProject project={current} server={server.conn} />)
}
return (
<Menu placement="bottom-start" gutter={4} shift={-10} modal={false}>
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
aria-label={projectName()}
data-slot="session-project-trigger"
icon={
<Show
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
</span>
}
>
<ProjectAvatar
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
</Show>
}
/>
</Tooltip>
<Menu.Portal>
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
<Tooltip
placement="top"
gutter={2}
disabled={!state.projectTruncated}
class="min-w-0 cursor-default"
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
value={<bdi>{projectName()}</bdi>}
>
<Menu.Item
class="min-w-0 w-full"
disabled={!props.project}
onSelect={() => {
if (!props.project) return
layout.home.setSelection({ server: server.key, directory: props.project.worktree })
navigate("/")
}}
>
<span class="session-project-link-content">
<ProjectAvatar
class="shrink-0"
aria-hidden="true"
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
<bdi
ref={(element) =>
createResizeObserver(element, () =>
setState("projectTruncated", element.scrollWidth > element.clientWidth),
)
}
class="min-w-0 truncate text-13-medium"
>
{projectName()}
</bdi>
</span>
</Menu.Item>
</Tooltip>
<Tooltip
placement="top"
gutter={2}
disabled={!state.pathTruncated}
class="min-w-0 cursor-default"
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
value={<bdi dir="ltr">{props.directory}</bdi>}
>
<Menu.Item
class="session-project-link min-w-0 w-full cursor-default"
disabled={!canOpenPath()}
onSelect={openPath}
>
<span class="session-project-link-content">
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
<bdi
ref={(element) =>
createResizeObserver(element, () =>
setState("pathTruncated", element.scrollWidth > element.clientWidth),
)
}
dir="ltr"
class="min-w-0 truncate text-v2-text-text-muted"
>
{props.directory}
</bdi>
</span>
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
<Icon name="arrow-up-right" />
</span>
</Menu.Item>
</Tooltip>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
@@ -245,13 +95,25 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
<SessionProjectMenu
project={project()}
directory={directory()}
workspace={workspaceSession()}
showProjectIcon={showProjectIcon()}
/>
<div class="flex min-w-0 w-full flex-1 items-center">
<span
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
}}
>
<Show
when={showProjectIcon()}
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
>
<ProjectAvatar
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
src={getProjectAvatarSource(project()?.id, project()?.icon)}
variant={getProjectAvatarVariant(project()?.icon?.color)}
/>
</Show>
</span>
<Show when={parentTitle()}>
{(value) => (
<button
@@ -279,7 +141,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
@@ -3,7 +3,6 @@ import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/p
import { createRoot } from "solid-js"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -64,7 +63,6 @@ describe("visibleTimelineMessages", () => {
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
timelineDetail: () => timelinePresets[2].value,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
@@ -21,7 +21,6 @@ import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessage
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
@@ -102,32 +101,12 @@ export function createTimelineController(input: { session: TimelineSessionSource
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const timelineDetail = createMemo(() => {
const detail = settings.general.timelineDetail()
return {
shell: { ...detail.shell },
edit: { ...detail.edit },
thinking: { ...detail.thinking },
subagents: { ...detail.subagents },
notices: { ...detail.notices },
tools: { ...detail.tools },
}
})
const reasoningMode = (): ReasoningMode =>
timelineDetail().thinking.placement === "hidden"
? "hidden"
: timelineDetail().thinking.details === "expanded"
? "full"
: "compact"
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
reasoningMode,
shellToolDefaultOpen: shellToolPartsExpanded,
editToolDefaultOpen: editToolPartsExpanded,
timelineDetail,
reasoningMode: settings.general.reasoningMode,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -256,10 +235,9 @@ export function createTimelineController(input: { session: TimelineSessionSource
childTitle,
showHeader,
projection,
timelineDetail,
reasoningMode,
shellToolPartsExpanded,
editToolPartsExpanded,
reasoningMode: settings.general.reasoningMode,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
@@ -10,6 +10,7 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode-ai/util/path"
@@ -30,9 +31,8 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
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 { SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
type BackgroundTask = {
id: string
@@ -384,6 +384,7 @@ function MessageTimelineView(
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
if (!session) return
return projectForSession(session, server.ctx.projects.list())
@@ -419,7 +420,6 @@ function MessageTimelineView(
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
showHeader,
pinned,
@@ -527,7 +527,6 @@ function MessageTimelineView(
reasoningMode: props.data.reasoningMode,
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
editToolDefaultOpen: props.data.editToolPartsExpanded,
timelineDetail: props.data.timelineDetail,
disclosure: virtualized.disclosure,
centered: () => props.centered,
padding: turnPadding,
@@ -633,28 +632,6 @@ function MessageTimelineView(
</div>
</div>
</Show>
<Show
when={
!showWorking() &&
!backgroundHintPresence.present() &&
props.background
.tasks()
.some(
(task) =>
props.data.timelineDetail()[task.type === "subagent" ? "subagents" : "shell"].placement !==
"separate",
)
}
>
<div
role="status"
class={`flex h-9 w-full min-w-0 items-center gap-2 pt-3 text-13-regular text-v2-text-text-muted ${turnPadding()}`}
classList={{ "md:max-w-[1000px] md:mx-auto": props.centered }}
>
<SessionProgressIndicatorV2 />
<span>{language.t("settings.timeline.running")}</span>
</div>
</Show>
</>
}
deferred={(row) => {
@@ -668,13 +645,36 @@ function MessageTimelineView(
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
<SessionProjectMenu
project={avatarProject()}
directory={sessionDirectory()}
workspace={workspaceSession()}
showProjectIcon={showProjectIcon()}
/>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
{projectAvatar()}
</Show>
</span>
}
>
<Tooltip
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
aria-label={sessionDirectory()}
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": !showProjectIcon(),
}}
>
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
{projectAvatar()}
</Show>
</span>
</Tooltip>
</Show>
<Show when={parentID()}>
<button
type="button"
@@ -698,7 +698,7 @@ function MessageTimelineView(
fallback={
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
onClick={openTitleEditor}
>
{childTitle()}
@@ -713,7 +713,7 @@ function MessageTimelineView(
dir="auto"
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
style={{
"--inline-input-shadow": "none",
"text-align": "start",
@@ -740,7 +740,7 @@ function MessageTimelineView(
{(id) => (
<Menu
gutter={6}
placement="bottom-start"
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
@@ -755,8 +755,7 @@ function MessageTimelineView(
/>
<Menu.Portal>
<Menu.Content
class="session-options-menu w-max"
style={{ "min-width": "0" }}
style={{ "min-width": "160px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
@@ -21,7 +21,7 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
@@ -33,15 +33,7 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<
string,
{
measurements: VirtualItem[]
toolOpen: Record<string, boolean | undefined>
patchGroupKeys: Map<string, string>
presentationKey?: string
}
>()
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
@@ -50,7 +42,6 @@ type Projection = Pick<
type Input = {
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
/** True while the timeline follows the newest content. Drives every anchoring decision. */
@@ -86,20 +77,11 @@ export function createTimelineVirtualizer(input: Input) {
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
const entry = cache.get(ownerSessionKey)
const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
createEffect(
on(
() => input.presentationKey?.(),
() => setToolOpen(reconcile({})),
{ defer: true },
),
)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
@@ -207,8 +189,7 @@ export function createTimelineVirtualizer(input: Input) {
},
scrollEndThreshold: 80,
get scrollMargin() {
// Empty projections still need the bottom spacer for running status.
return rows().length > 0 ? topOffset() : 0
return topOffset()
},
paddingEnd: 64,
get rangeExtractor() {
@@ -559,13 +540,15 @@ export function createTimelineVirtualizer(input: Input) {
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
<Show when={rows().length > 0}>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
</Show>
</div>
</ScrollView>
</div>
@@ -574,12 +557,7 @@ export function createTimelineVirtualizer(input: Input) {
onCleanup(() => {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, {
measurements: virtualizer.takeSnapshot(),
toolOpen: { ...toolOpen },
patchGroupKeys,
presentationKey: input.presentationKey?.(),
})
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
coldPending = false
contentObserver?.disconnect()
@@ -591,7 +569,6 @@ export function createTimelineVirtualizer(input: Input) {
return {
disclosure: {
patchGroupKeys,
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
+55 -13
View File
@@ -4,7 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { TimelineDetailControl } from "@/settings/timeline-detail"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
@@ -184,6 +184,34 @@ const FollowUpBehaviorSetting: Component = () => {
)
}
const ReasoningModeSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
])
return (
<SettingsRow
title={language.t("settings.general.row.reasoningMode.title")}
description={language.t("settings.general.row.reasoningMode.description")}
>
<Select
data-action="settings-reasoning-mode"
options={options()}
current={options().find((option) => option.value === settings.general.reasoningMode())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -332,6 +360,8 @@ export const SettingsGeneral: Component<{
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<ReasoningModeSetting />
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -348,6 +378,30 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
@@ -531,18 +585,6 @@ export const SettingsGeneral: Component<{
<div class="settings-tab-body">
<GeneralSection />
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
<h3 class="settings-section-title">{language.t("settings.timeline.title")}</h3>
<SettingsList>
<div class="py-5">
<TimelineDetailControl
value={settings.general.timelineDetail()}
onChange={settings.general.setTimelineDetail}
/>
</div>
</SettingsList>
</section>
<Show when={desktop()}>
<UpdatesSection />
</Show>
+53 -20
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
@@ -17,19 +16,54 @@ const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
const settings = decode({
general: { shellToolPartsExpanded: true, editToolPartsExpanded: false, showReasoningSummaries: true },
appearance: { fontSize: 16 },
})
expect(settings.general.timelineDetail).toEqual({
...timelinePresets[2].value,
shell: { placement: "separate", details: "expanded" },
thinking: { placement: "separate", details: "expanded" },
})
describe("settings reasoning mode migration", () => {
test.each([
[true, "full"],
[false, "compact"],
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
const settings = decode(value)
expect(settings.general.reasoningMode).toBe(reasoningMode)
expect(settings.general.showTerminal).toBe(true)
expect(settings.appearance.fontSize).toBe(16)
expect(decode(encode(settings))).toEqual(settings)
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
expect(value.general).not.toHaveProperty("reasoningMode")
})
test.each(["hidden", "compact", "full"])(
"preserves an explicit %s mode over either legacy value",
(reasoningMode) => {
;[true, false].forEach((showReasoningSummaries) => {
const value = { general: { reasoningMode, showReasoningSummaries } }
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
})
},
)
test.each([undefined, null, {}, { showReasoningSummaries: "true" }])(
"defaults invalid or absent legacy settings: %j",
(general) => {
expect(decode({ general }).general.reasoningMode).toBe("compact")
},
)
test("migrates an undefined current mode but defaults an invalid current mode", () => {
expect(decode({ general: { reasoningMode: undefined, showReasoningSummaries: true } }).general.reasoningMode).toBe(
"full",
)
expect(decode({ general: { reasoningMode: "invalid", showReasoningSummaries: true } }).general.reasoningMode).toBe(
"compact",
)
})
test("encodes only the current format and round trips migrated settings", () => {
const settings = decode({ general: { showReasoningSummaries: true, obsolete: true }, obsolete: true })
const encoded = encode(settings)
expect(encoded).toEqual(settings)
expect(encoded).not.toHaveProperty("obsolete")
expect(encoded).not.toHaveProperty("general.obsolete")
expect(encoded).not.toHaveProperty("general.showReasoningSummaries")
expect(decode(encoded)).toEqual(settings)
})
})
@@ -37,16 +71,13 @@ describe("settings schema", () => {
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(restore({ general: { showReasoningSummaries: true } }).general.reasoningMode).toBe("full")
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
@@ -61,7 +92,9 @@ describe("settings schema", () => {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: timelinePresets[2].value,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -111,7 +144,7 @@ describe("settings schema", () => {
showTerminal: true,
autoSave: false,
releaseNotes: true,
timelineDetail: timelinePresets[2].value,
reasoningMode: "compact",
followUpBehavior: "steer",
})
expect(settings.appearance).toEqual({
+31 -89
View File
@@ -1,8 +1,8 @@
import { reconcile, unwrap } from "solid-js/store"
import { reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
@@ -68,10 +68,7 @@ export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const placementSchema = Schema.Literals(["separate", "grouped", "hidden"])
const detailsSchema = Schema.Literals(["collapsed", "expanded"])
const activitySchema = Persistence.struct({ placement: placementSchema, details: detailsSchema })
const placementOnlySchema = Persistence.struct({ placement: placementSchema })
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
const generalSchema = Persistence.struct({
autoSave: Schema.Boolean,
@@ -82,14 +79,9 @@ const generalSchema = Persistence.struct({
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
timelineDetail: Persistence.struct({
shell: activitySchema,
edit: activitySchema,
thinking: activitySchema,
subagents: placementOnlySchema,
notices: placementOnlySchema,
tools: placementOnlySchema,
}),
reasoningMode: reasoningModeSchema,
shellToolPartsExpanded: Schema.Boolean,
editToolPartsExpanded: Schema.Boolean,
showCustomAgents: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
mobileDiffWrap: Schema.Boolean,
@@ -142,91 +134,25 @@ export const settingsSchema = Persistence.struct({
sounds: soundsSchema,
})
function storedTimelineCategory(category: TimelineCategory) {
return Persistence.optional(
Schema.Union([
Schema.Struct({
placement: Persistence.optional(placementSchema),
details: Persistence.optional(detailsSchema),
}),
Schema.Literals(["expanded", "collapsed", "hidden", "visible"]),
]).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (typeof value !== "string") return value
return {
placement:
value === "hidden"
? "hidden"
: category === "subagents"
? "separate"
: category === "tools"
? "grouped"
: value === "expanded"
? "separate"
: value === "collapsed"
? "grouped"
: undefined,
details: value === "expanded" ? "expanded" : "collapsed",
}
}),
encode: SchemaGetter.passthrough(),
}),
),
)
}
function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) {
if (value === undefined || value === null) return
const expanded = value === true || value === "full"
return {
placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped",
details: expanded ? "expanded" : "collapsed",
} as const
}
export const settingsPersistence = Persistence.migrate(
settingsSchema,
Schema.Struct({
general: Persistence.optional(
Schema.Struct({
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
timelineDetail: Schema.optional(
Schema.NullOr(
Schema.Struct({
shell: storedTimelineCategory("shell"),
edit: storedTimelineCategory("edit"),
thinking: storedTimelineCategory("thinking"),
subagents: storedTimelineCategory("subagents"),
notices: storedTimelineCategory("notices"),
tools: storedTimelineCategory("tools"),
}),
),
).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))),
reasoningMode: Schema.optional(Schema.NullOr(Schema.Literals(["hidden", "compact", "full"]))).pipe(
Schema.catchDecoding(() => Effect.succeed(Option.some(null))),
),
reasoningMode: Schema.optional(Schema.Unknown),
showReasoningSummaries: Persistence.optional(Schema.Boolean),
shellToolPartsExpanded: Persistence.optional(Schema.Boolean),
editToolPartsExpanded: Persistence.optional(Schema.Boolean),
}),
),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const general = value.general
if (!general || general.timelineDetail !== undefined) return value
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
return value
return {
...value,
general: {
...general,
timelineDetail: {
shell: legacyTimelineActivity(general.shellToolPartsExpanded),
edit: legacyTimelineActivity(general.editToolPartsExpanded),
thinking: legacyTimelineActivity(
general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode,
),
},
...value.general,
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
},
}
}),
@@ -245,7 +171,9 @@ export const defaultSettings: Settings = {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: { ...timelinePresets[2].value },
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -328,9 +256,23 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail),
setTimelineDetail(value: TimelineDetail) {
setStore("general", "timelineDetail", structuredClone(unwrap(value)))
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
setReasoningMode(value: ReasoningMode) {
setStore("general", "reasoningMode", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
defaultSettings.general.shellToolPartsExpanded,
),
setShellToolPartsExpanded(value: boolean) {
setStore("general", "shellToolPartsExpanded", value)
},
editToolPartsExpanded: withFallback(
() => store.general?.editToolPartsExpanded,
defaultSettings.general.editToolPartsExpanded,
),
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
showCustomAgents,
setShowCustomAgents(value: boolean) {
@@ -1,249 +0,0 @@
[data-component="timeline-detail-control"] {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
container: timeline-detail / inline-size;
[data-slot="timeline-detail-heading"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
font-weight: 530;
line-height: var(--line-height-compact);
}
[data-slot="timeline-detail-current"] {
color: var(--v2-text-text-muted);
text-align: end;
}
p {
margin: 0;
color: var(--v2-text-text-muted);
}
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
}
[data-slot="timeline-detail-track"] {
position: absolute;
inset-inline: 8px;
top: 12px;
height: 4px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 2px;
background: var(--v2-background-bg-layer-03);
pointer-events: none;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--v2-border-border-strong);
}
}
input[type="range"] {
appearance: none;
position: relative;
display: block;
width: 100%;
height: 28px;
margin: 0;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
margin-top: -6px;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
&::-moz-range-track {
height: 4px;
background: transparent;
}
&::-moz-range-thumb {
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
}
[data-slot="timeline-detail-advanced"] {
margin-top: 4px;
padding-top: 8px;
border-top: 0.5px solid var(--v2-border-border-base);
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
width: fit-content;
height: 28px;
align-self: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: inherit;
color: var(--v2-text-text-muted);
[data-slot="collapsible-arrow"] {
width: 16px;
}
&:focus-visible [data-slot="collapsible-arrow"] {
opacity: 1;
}
&:dir(rtl):not([aria-expanded="true"]) [data-slot="collapsible-arrow-icon"] {
transform: rotate(90deg);
}
}
[data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
display: flex;
flex-direction: column;
margin-top: 8px;
}
[data-slot="timeline-detail-explainer"] {
margin-bottom: 12px;
}
[data-slot="timeline-detail-field-label"] {
display: none;
}
[data-slot="timeline-detail-columns"],
[data-slot="timeline-detail-category"] {
display: grid;
grid-template-columns: var(--timeline-detail-columns);
align-items: center;
column-gap: 8px;
line-height: var(--line-height-compact);
> span {
min-width: 0;
overflow-wrap: normal;
}
}
[data-slot="timeline-detail-columns"] {
min-height: 28px;
color: var(--v2-text-text-muted);
span:not(:first-child) {
padding-inline-start: 8px;
}
}
[data-slot="timeline-detail-category"] {
min-height: 40px;
padding-block: 8px;
border-bottom: 0.5px solid var(--v2-border-border-base);
&:last-child {
border-bottom: 0;
}
[data-component="select-v2-root"],
[data-slot="timeline-detail-placement"],
[data-slot="timeline-detail-expansion"] {
min-width: 0;
}
[data-component="select-v2-root"][data-field] {
width: 100%;
}
[data-component="select-v2"][data-appearance="inline"] {
width: 100%;
}
}
}
@container timeline-detail (max-width: 250px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
flex-wrap: wrap;
gap: 4px 8px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
margin-inline-start: auto;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
display: none;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;
padding-block: 12px;
> span {
font-weight: 530;
}
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
display: block;
margin-bottom: 2px;
padding-inline-start: 8px;
font-size: 12px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
display: none;
}
}
@container timeline-detail (max-width: 310px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
column-gap: 4px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
min-height: 48px;
}
}
@@ -1,127 +0,0 @@
import { For, Show, createMemo, createUniqueId } from "solid-js"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { Select } from "@opencode-ai/ui/select"
import {
timelineCategories,
timelinePreset,
timelinePresets,
type TimelineDetail,
type TimelineExpansion,
type TimelinePlacement,
} from "@opencode-ai/session-ui/timeline/detail"
import { useLanguage } from "@/runtime/i18n/language"
import "./timeline-detail.css"
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
const language = useLanguage()
const id = createUniqueId()
const preset = createMemo(() => timelinePreset(props.value))
const position = () => {
const current = preset()
return current ? timelinePresets.indexOf(current) : 2
}
const label = () => {
const current = preset()
return current ? language.t(`settings.timeline.preset.${current.id}`) : language.t("settings.timeline.custom")
}
return (
<div data-component="timeline-detail-control">
<div data-slot="timeline-detail-heading">
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
<span data-slot="timeline-detail-current" aria-live="polite">
{label()}
</span>
</div>
<p id={`${id}-description`} class="sr-only">
{language.t("settings.timeline.description")}
</p>
<div data-slot="timeline-detail-scale">
<div data-slot="timeline-detail-track" aria-hidden="true">
<For each={timelinePresets}>{() => <span />}</For>
</div>
<input
id={`${id}-slider`}
data-action="settings-timeline-detail"
type="range"
min="0"
max={timelinePresets.length - 1}
step="1"
value={position()}
aria-valuetext={label()}
aria-describedby={`${id}-description ${id}-preset-description`}
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
/>
</div>
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
<Collapsible.Trigger>
<span>{language.t("settings.timeline.advanced")}</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div
data-slot="timeline-detail-categories"
role="group"
aria-label={language.t("settings.timeline.advanced.description")}
>
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
<div data-slot="timeline-detail-columns">
<span>{language.t("settings.timeline.activity")}</span>
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
</div>
<For each={timelineCategories}>
{(category) => (
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.placement.title")}
</span>
<Select
data-category={category}
data-field="placement"
aria-labelledby={`${id}-${category} ${id}-placement`}
options={placements}
current={props.value[category].placement}
label={(value) => language.t(`settings.timeline.placement.${value}`)}
onSelect={(placement) =>
placement &&
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
}
/>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<Show when={props.value[category].placement !== "hidden"}>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.expansion.title")}
</span>
<Select
data-category={category}
data-field="details"
aria-labelledby={`${id}-${category} ${id}-expansion`}
options={expansions}
current={props.value[category].details}
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
onSelect={(details) =>
details &&
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
}
/>
</Show>
) : null}
</div>
</div>
)}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</div>
)
}
+2 -2
View File
@@ -1180,8 +1180,8 @@ export function createData(config: CreateDataInput) {
}))
break
case "reference.updated":
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
result.location.reference.invalidate()
void result.location.reference.sync()
break
case "integration.updated":
result.location.integration.invalidate(location)
-60
View File
@@ -409,66 +409,6 @@ test("refreshes global credential events across every loaded location and worksp
}
})
test("refreshes references for the location an update names", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const other = { directory: "/other", workspaceID: "workspace-other" }
try {
await Promise.all([setup.data.location.reference.sync(), setup.data.location.reference.sync(other)])
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_reference.updated",
created: 1,
type: "reference.updated",
location: other,
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 1)
expect([
requests[0]!.pathname,
requests[0]!.searchParams.get("location[directory]"),
requests[0]!.searchParams.get("location[workspace]"),
]).toEqual(["/api/reference", "/other", "workspace-other"])
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
-1
View File
@@ -100,7 +100,6 @@ const layer = Layer.effect(
editor.providers.set(providerID, current)
}
fn(current.provider)
current.provider.id = providerID
},
remove: (providerID) => {
editor.providers.delete(providerID)
@@ -50,14 +50,16 @@ const layer = Layer.effect(
),
)
const lock = Semaphore.makeUnsafe(1)
let requested = 0
let stopped = false
let active: { path: string; scope: Scope.Closeable } | undefined
const reconcile = () =>
lock.withPermit(
const reconcile = (ignore: readonly string[]) => {
const request = ++requested
return lock.withPermit(
Effect.gen(function* () {
if (stopped) return
if (stopped || request !== requested) return
const resolved = yield* target
const ignore = policy.current()
if (stopped || request !== requested) return
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
if (active?.path === next) return
if (active) yield* Scope.close(active.scope, Exit.void)
@@ -77,10 +79,12 @@ const layer = Layer.effect(
)
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
)
}
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.gen(function* () {
stopped = true
requested++
if (active) yield* Scope.close(active.scope, Exit.void)
active = undefined
}),
@@ -89,7 +93,7 @@ const layer = Layer.effect(
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* Plugin.awaitActivation
yield* reconcile()
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
-2
View File
@@ -397,7 +397,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
add: (name, source) => editor.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: editor.remove,
list: editor.list,
get: editor.get,
})
}),
},
@@ -408,7 +407,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
skill.transform((editor) => {
callback({
list: () => mutable(editor.list()),
get: editor.get,
add: (value) => editor.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: editor.update,
remove: editor.remove,
-2
View File
@@ -32,7 +32,6 @@ type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
get(name: string): Source | undefined
}
export interface Interface extends State.Transformable<Editor> {
@@ -99,7 +98,6 @@ const layer = Layer.effect(
add: (name, source) => editor.sources.set(name, source),
remove: (name) => editor.sources.delete(name),
list: () => Array.from(editor.sources),
get: (name) => editor.sources.get(name),
}),
notify: () =>
Effect.gen(function* () {
+4 -1
View File
@@ -18,6 +18,7 @@ import { KV } from "./kv.js"
const Refresh = Schema.Struct({
attemptedAt: Schema.Number,
refreshedAt: Schema.optionalKey(Schema.Number),
})
const refreshInterval = Duration.toMillis(Duration.days(1))
@@ -160,7 +161,7 @@ const layer = Layer.effect(
if (status !== "cached") {
// Record attempts before network work so failures obey the same refresh interval.
yield* kv.set(key, { attemptedAt: now })
yield* kv.set(key, { ...previous, attemptedAt: now })
if (status === "cloned") {
yield* git.repo
@@ -204,6 +205,8 @@ const layer = Layer.effect(
.resetHard(existing, target ? `origin/${target}` : "HEAD")
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
yield* kv.set(key, { attemptedAt: now, refreshedAt: yield* Clock.currentTimeMillis })
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
+9 -1
View File
@@ -121,7 +121,15 @@ const layer = Layer.effect(
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(Effect.catch((error) => encodeError(method, error)))
}).pipe(
Effect.catch((error) => encodeError(method, error)),
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
Effect.catchDefect((defect) =>
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
),
),
)
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
-2
View File
@@ -74,7 +74,6 @@ export type Data = {
export type Editor = {
list: () => readonly Types.DeepMutable<Info>[]
get: (id: string) => Types.DeepMutable<Info> | undefined
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
@@ -97,7 +96,6 @@ const layer = Layer.effect(
initial: () => ({ skills: new Map() }),
editor: (editor) => ({
list: () => Array.from(editor.skills.values()),
get: (id) => editor.skills.get(ID.make(id)),
add: (skill) => {
editor.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
-26
View File
@@ -93,32 +93,6 @@ describe("Catalog", () => {
}),
)
it.effect("preserves provider identity when updating new and existing providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("original")
const renamed = Provider.ID.make("renamed")
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.id = renamed
provider.name = "Created"
})
expect(editor.provider.get(providerID)?.provider.id).toBe(providerID)
editor.provider.update(providerID, (provider) => {
provider.id = renamed
provider.name = "Updated"
})
})
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
expect(yield* catalog.provider.get(renamed)).toBeUndefined()
expect((yield* catalog.provider.all()).map((provider) => provider.id)).toEqual([providerID])
yield* catalog.reload()
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const localCatalogLayer = Layer.fresh(
+1 -72
View File
@@ -16,7 +16,6 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@@ -126,7 +125,6 @@ function provide(
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins?: LayerNode.Replacement,
replacements: LayerNode.Replacements = [],
) {
const locationLayer = Layer.succeed(
Location.Service,
@@ -139,7 +137,6 @@ function provide(
Location.node.replace(locationLayer),
plugins ?? PluginSupervisor.node.replace(Layer.empty),
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
...replacements,
],
)
return Effect.provide(built)
@@ -153,7 +150,6 @@ function withTmp<A, E, R>(
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: LayerNode.Replacement
replacements?: LayerNode.Replacements
},
) {
return Effect.acquireRelease(
@@ -176,16 +172,7 @@ function withTmp<A, E, R>(
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap(({ tmp, vcs }) =>
f(tmp.path, vcs).pipe(
provide(
tmp.path,
vcs,
options?.watcher,
options?.config ?? configLayer,
options?.plugins,
options?.replacements,
),
),
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
),
)
}
@@ -306,64 +293,6 @@ describe("LocationWatcher subscriptions", () => {
})
})
it.live("uses the policy changed while target discovery was suspended", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const discovering = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const subscribed = yield* Deferred.make<void>()
const subscriptions: Watcher.WatchInput[] = []
let released = 0
yield* withTmp(
(directory) =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* Deferred.await(discovering)
const update = yield* policy
.transform((editor) => editor.add([".hg"]))
.pipe(Effect.forkScoped({ startImmediately: true }))
expect(policy.current()).toEqual([".hg"])
yield* Deferred.succeed(release, undefined)
const registration = yield* Fiber.join(update)
expect(subscriptions).toEqual([])
yield* registration.dispose
yield* Deferred.await(subscribed)
yield* policy.reload()
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
expect(released).toBe(0)
}),
{
vcs: "hg",
replacements: [
Plugin.node.replace(Layer.mock(Plugin.Service, { awaitActivation: Effect.void })),
FSUtil.node.replace(
Layer.succeed(FSUtil.Service, {
...fs,
realPath: (target) =>
Deferred.succeed(discovering, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(fs.realPath(target)),
),
}),
),
],
watcher: Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) =>
Effect.sync(() => subscriptions.push(input)).pipe(
Effect.andThen(Deferred.succeed(subscribed, undefined)),
Effect.as(Stream.never.pipe(Stream.ensuring(Effect.sync(() => released++)))),
),
}),
),
},
)
expect(released).toBe(1)
}),
)
it.live("does not start before configured policy is ready", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
-20
View File
@@ -19,26 +19,6 @@ const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus
])
describe("Reference", () => {
it.effect("reads the current editor source by name", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const source = Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") })
yield* references.transform((editor) => editor.add("docs", source))
yield* references.transform((editor) => {
expect(editor.get("docs")).toBe(editor.list()[0]?.[1])
expect(editor.get("docs")).toEqual(source)
expect(editor.get("missing")).toBeUndefined()
const replacement = Reference.GitSource.make({ type: "git", repository: "owner/repo" })
editor.add("docs", replacement)
expect(editor.get("docs")).toBe(replacement)
editor.remove("docs")
expect(editor.get("docs")).toBeUndefined()
})
expect(yield* references.list()).toEqual([])
}).pipe(Effect.provide(referenceLayer)),
)
it.effect("reads batched references before cache work and update events", () => {
const operations: RepositoryCache.EnsureInput[] = []
const started = Deferred.makeUnsafe<void>()
+15 -30
View File
@@ -2,7 +2,7 @@ import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { Clock, Duration, Effect, Layer } from "effect"
import { Clock, Duration, Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
@@ -35,7 +35,7 @@ describe("RepositoryCache", () => {
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday })
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
const results = yield* Effect.all(
@@ -61,30 +61,7 @@ describe("RepositoryCache", () => {
),
)
it.live("honors legacy attempt records while allowing forced refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
// Persisted records from older versions include an unused success timestamp.
yield* kv.set(`repository-cache:${initial.localPath}`, {
attemptedAt: yield* Clock.currentTimeMillis,
refreshedAt: 0,
})
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
expect((yield* cache.ensure({ reference: fixture.reference, refresh: true })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("throttles failed refresh attempts until the next interval", () =>
it.live("throttles failed refresh attempts without marking them successful", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
@@ -92,23 +69,31 @@ describe("RepositoryCache", () => {
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const key = `repository-cache:${initial.localPath}`
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(key, { attemptedAt: yesterday })
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* Effect.promise(() =>
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
)
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
const stored = yield* kv.get(key)
const stamp = Schema.decodeUnknownSync(
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
)(stored)
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
expect(stamp.refreshedAt).toBe(yesterday)
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* kv.get(key)).toEqual(stored)
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
yield* Effect.promise(async () => {
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
await commit(fixture.source, "two\n", "advance main")
})
yield* kv.set(key, { attemptedAt: yesterday })
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect(yield* kv.get(key)).not.toEqual(stored)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
@@ -172,13 +157,13 @@ describe("RepositoryCache", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const initial = yield* cache.ensure({ reference: fixture.reference })
yield* Effect.promise(async () => {
await git(initial.localPath, "config", "remote.origin.url", "https://github.com/other/repo.git")
await fs.writeFile(path.join(initial.localPath, "stale.txt"), "stale")
})
const replaced = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const replaced = yield* cache.ensure({ reference: fixture.reference })
expect(replaced.status).toBe("cloned")
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
@@ -0,0 +1,62 @@
import { expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer, Logger, Schema } from "effect"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Rpc.node, [
Location.node.replace(Layer.succeed(Location.Service, location({ directory: AbsolutePath.make("/rpc-project") }))),
]),
)
const Broken = Rpc.define({
id: "broken",
methods: {
dies: { input: Schema.Undefined, output: Schema.String },
throws: { input: Schema.Undefined, output: Schema.String },
raw: { input: Schema.Undefined, output: Schema.String },
undeclared: { input: Schema.Undefined, output: Schema.String },
invalidError: {
input: Schema.Undefined,
output: Schema.String,
errors: { known: Schema.Struct({ count: Schema.Int }) },
},
},
events: {},
})
for (const method of ["dies", "throws", "raw", "undeclared", "invalidError"] as const) {
it.effect(`recovers from ${method} through the typed rpc.internal failure`, () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Broken, {
dies: () => Effect.die(new Error("handler defect")),
throws: () => {
throw new Error("handler threw")
},
// Raw Promise rejections reach this boundary as failed Effects.
// @ts-expect-error intentionally exercise an undeclared failure
raw: () => Effect.fail(new Error("raw failure")),
// @ts-expect-error intentionally exercise an undeclared error name
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
invalidError: (_input, context) => Effect.fail(context.error("known", "Invalid count", { count: 1.5 })),
})
const logged: unknown[] = []
const result = yield* rpc
.client(Broken)
[method]()
.pipe(
Effect.catchIf(
(error) => "type" in error && error.type === "rpc.internal",
(error) => Effect.succeed(error),
),
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
)
expect(result).toEqual({ type: "rpc.internal", message: "RPC call failed" })
expect(logged).toHaveLength(1)
}),
)
}
-19
View File
@@ -20,25 +20,6 @@ const info = (id: string, description: string) =>
})
describe("Skill", () => {
it.effect("reads the current editor entry by ID", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.add(info("review", "Initial")))
yield* skill.transform((editor) => {
expect(editor.get("review")).toBe(editor.list()[0])
expect(editor.get("missing")).toBeUndefined()
editor.update("review", (value) => {
value.description = "Updated"
})
expect(editor.get("review")?.description).toBe("Updated")
editor.remove("review")
expect(editor.get("review")).toBeUndefined()
})
expect(yield* skill.list()).toEqual([])
}),
)
it.effect("registers values with last-write-wins precedence", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
-1
View File
@@ -7,7 +7,6 @@ export interface ReferenceEditor {
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
}
export interface ReferenceDomain extends ReferenceApi<unknown> {
-1
View File
@@ -5,7 +5,6 @@ import type { Transform } from "./registration.js"
export interface SkillEditor {
list(): readonly Types.DeepMutable<Skill.Info>[]
get(id: string): Types.DeepMutable<Skill.Info> | undefined
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
-1
View File
@@ -6,7 +6,6 @@ export interface ReferenceEditor {
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
}
export interface ReferenceDomain extends ReferenceApi {
-1
View File
@@ -5,7 +5,6 @@ import type { DeepMutable } from "./types.js"
export interface SkillEditor {
list(): readonly DeepMutable<Skill.Info>[]
get(id: string): DeepMutable<Skill.Info> | undefined
add(skill: Skill.Info): void
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
remove(id: string): void
+1 -1
View File
@@ -22,7 +22,7 @@ export type Info<
) => Promise<Tool.Result<Output>>
}
export interface ToolEditor {
interface ToolEditor {
list(): readonly (Info & { readonly id: string })[]
get(id: string): (Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
+5 -7
View File
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
return output === undefined ? {} : { output }
}).pipe(
Effect.mapError((error) =>
error.type === "rpc.invalid_output"
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
? new RpcInternalError({ type: error.type, message: error.message })
: new RpcError({
type: error.type,
@@ -22,12 +22,10 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
...(error.data === undefined ? {} : { data: error.data }),
}),
),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
// Defects outside handler execution are still logged, never echoed to the client.
Effect.catchDefect((defect) =>
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
),
),
),
@@ -0,0 +1,74 @@
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Context, Effect, Layer, Schema } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes } from "../src/routes"
const Broken = Rpc.define({
id: "broken",
methods: {
handler: { input: Schema.String, output: Schema.String },
schema: {
input: Schema.String.check(
Schema.makeFilter(() => {
throw new Error("private schema detail")
}),
),
output: Schema.String,
},
},
events: {},
})
for (const method of ["handler", "schema"] as const) {
it.live(`returns HTTP 500 without exposing the ${method} defect`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const context = yield* Layer.build(
createEmbeddedRoutes({
database: { path: ":memory:" },
models: { fetch: false },
config: { directory: directory.path, project: false, content: "{}" },
fs: { filewatcher: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* sdk.register(
define({
id: "broken-rpc",
effect: (ctx) =>
ctx.rpc
.register(Broken, {
handler: () => Effect.die(new Error("private handler detail")),
schema: Effect.succeed,
})
.pipe(Effect.asVoid, Effect.orDie),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
url.searchParams.set("location[directory]", directory.path)
const response = yield* Effect.promise(() =>
handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input: "hello" }),
}),
),
)
expect(response.status).toBe(500)
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "RPC call failed",
})
}),
)
}
-1
View File
@@ -9,7 +9,6 @@
"./document": "./src/document.ts",
"./message": "./src/message/current-message.tsx",
"./timeline/projection": "./src/timeline/projection.ts",
"./timeline/detail": "./src/timeline/detail.ts",
"./timeline/row": "./src/timeline/session-timeline-row.tsx",
"./timeline": "./src/timeline/session-timeline.tsx",
"./basic-tool": "./src/components/basic-tool.tsx",
@@ -252,25 +252,13 @@
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-faint);
margin-inline-start: auto;
opacity: 0;
transform: translateX(-4px);
margin-left: auto;
transition:
opacity 0.15s ease,
color 0.15s ease,
transform 0.15s ease;
color 0.15s ease;
:dir(rtl) & {
transform: translateX(4px);
}
@media (hover: none) {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
@media (hover: hover) {
opacity: 0;
}
}
@@ -297,7 +285,6 @@
[data-component="task-tool-action"] {
opacity: 1;
transform: none;
}
}
}
@@ -3,10 +3,15 @@ import type {
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import { Match, Switch, type ComponentProps } from "solid-js"
import { Match, Switch } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
import {
CurrentContextToolGroup,
CurrentFileToolGroup,
ToolDisplay,
type ContextGroupPart,
} from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -96,8 +101,28 @@ export function SessionAssistantContent(props: {
)
}
export function SessionContextToolGroup(props: ComponentProps<typeof CurrentContextToolGroup>) {
return <CurrentContextToolGroup {...props} />
export function SessionContextToolGroup(props: {
parts: ContextGroupPart[]
reasoningDefaultOpen?: boolean
reasoningOpen?: (id: string) => boolean | undefined
onReasoningOpenChange?: (id: string, open: boolean) => void
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
onSizeChange?: () => void
}) {
return (
<CurrentContextToolGroup
parts={props.parts}
reasoningDefaultOpen={props.reasoningDefaultOpen}
reasoningOpen={props.reasoningOpen}
onReasoningOpenChange={props.onReasoningOpenChange}
open={props.open}
busy={props.busy}
onOpenChange={props.onOpenChange}
onSizeChange={props.onSizeChange}
/>
)
}
export function SessionFileToolGroup(props: {
@@ -29,42 +29,6 @@ export function currentToolError(tool: SessionMessageAssistantTool) {
return tool.state.error.message
}
export function currentToolFailed(tool: SessionMessageAssistantTool) {
return (
tool.state.status === "error" ||
(tool.name === "execute" && executeToolFailed(currentToolMetadata(tool))) ||
(tool.name === "shell" && tool.state.status === "completed" && shellResultFailed(currentToolMetadata(tool)))
)
}
export function shellResultFailed(metadata: Record<string, unknown>) {
// Shell completion reports the process outcome in metadata, not the tool status.
return metadata.timeout === true || (typeof metadata.exit === "number" && metadata.exit !== 0)
}
export function executeToolFailed(metadata: Record<string, unknown>) {
// Code Mode can report failed nested calls in a completed tool result.
const calls = metadata.toolCalls
return (
metadata.error === true ||
(Array.isArray(calls) &&
calls.some(
(call) =>
call !== null &&
typeof call === "object" &&
!Array.isArray(call) &&
"status" in call &&
call.status === "error",
))
)
}
export function currentToolHasLoadedFiles(tool: SessionMessageAssistantTool) {
if (tool.name !== "read" || tool.state.status !== "completed") return false
const loaded = tool.state.metadata?.loaded
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
export function currentContentDefaultOpen(
content: SessionMessageAssistant["content"][number],
shellExpanded: boolean,
-109
View File
@@ -1,109 +0,0 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { shellResultFailed } from "../message/current-tool-state"
export const timelineCategories = ["shell", "edit", "thinking", "subagents", "notices", "tools"] as const
export type TimelineCategory = (typeof timelineCategories)[number]
export type TimelinePlacement = "separate" | "grouped" | "hidden"
export type TimelineExpansion = "collapsed" | "expanded"
export type TimelineDetail = {
shell: { placement: TimelinePlacement; details: TimelineExpansion }
edit: { placement: TimelinePlacement; details: TimelineExpansion }
thinking: { placement: TimelinePlacement; details: TimelineExpansion }
subagents: { placement: TimelinePlacement }
notices: { placement: TimelinePlacement }
tools: { placement: TimelinePlacement }
}
export const timelinePresets = [
{
id: "everything",
value: {
shell: { placement: "separate", details: "expanded" },
edit: { placement: "separate", details: "expanded" },
thinking: { placement: "separate", details: "expanded" },
subagents: { placement: "separate" },
notices: { placement: "separate" },
tools: { placement: "separate" },
},
},
{
id: "detailed",
value: {
shell: { placement: "separate", details: "expanded" },
edit: { placement: "separate", details: "expanded" },
thinking: { placement: "grouped", details: "collapsed" },
subagents: { placement: "separate" },
notices: { placement: "grouped" },
tools: { placement: "grouped" },
},
},
{
id: "compact",
value: {
shell: { placement: "grouped", details: "collapsed" },
edit: { placement: "grouped", details: "collapsed" },
thinking: { placement: "grouped", details: "collapsed" },
subagents: { placement: "grouped" },
notices: { placement: "grouped" },
tools: { placement: "grouped" },
},
},
{
id: "quiet",
value: {
shell: { placement: "hidden", details: "collapsed" },
edit: { placement: "grouped", details: "collapsed" },
thinking: { placement: "hidden", details: "collapsed" },
subagents: { placement: "grouped" },
notices: { placement: "hidden" },
tools: { placement: "hidden" },
},
},
{
id: "text-only",
value: {
shell: { placement: "hidden", details: "collapsed" },
edit: { placement: "hidden", details: "collapsed" },
thinking: { placement: "hidden", details: "collapsed" },
subagents: { placement: "hidden" },
notices: { placement: "hidden" },
tools: { placement: "hidden" },
},
},
] as const satisfies readonly { id: string; value: TimelineDetail }[]
export function timelinePreset(value: TimelineDetail) {
return timelinePresets.find((preset) =>
timelineCategories.every((category) => {
const current = value[category]
const expected = preset.value[category]
return (
current.placement === expected.placement &&
(current.placement === "hidden" ||
!("details" in current) ||
("details" in expected && current.details === expected.details))
)
}),
)
}
export function timelineCategory(
content: SessionMessageAssistant["content"][number],
): keyof TimelineDetail | undefined {
if (content.type === "text") return
if (content.type === "reasoning") return "thinking"
if (["shell", "execute", "bash"].includes(content.name)) return "shell"
if (["edit", "write", "patch", "apply_patch"].includes(content.name)) return "edit"
if (["subagent", "task"].includes(content.name)) return "subagents"
return "tools"
}
export function timelineNoticeRequired(message: SessionMessageInfo) {
if (message.type === "compaction") return message.status !== "completed"
if (message.type !== "synthetic") return false
const metadata = message.metadata
return (
metadata?.state === "error" ||
(metadata?.source === "shell" && metadata.state === "completed" && shellResultFailed(metadata))
)
}
+48 -144
View File
@@ -8,9 +8,8 @@ import type {
} from "@opencode-ai/client/promise"
import { Option, Schema } from "effect"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
import { currentContentDefaultOpen } from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
@@ -30,7 +29,6 @@ export type TimelineProjectionInput = {
reasoningMode: ReasoningMode
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
timelineDetail?: TimelineDetail
pendingUserMessageIDs?: ReadonlySet<string>
previousRows?: TimelineRow.TimelineRow[]
}
@@ -44,8 +42,6 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
input.pendingUserMessageIDs,
input.shellToolDefaultOpen ?? false,
input.editToolDefaultOpen ?? false,
undefined,
input.timelineDetail,
)
const rows = reuseTimelineRows(input.previousRows, projection.rows)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const))
@@ -79,7 +75,6 @@ export function createReactiveTimelineProjection(input: {
reasoningMode: Accessor<ReasoningMode>
shellToolDefaultOpen?: Accessor<boolean>
editToolDefaultOpen?: Accessor<boolean>
timelineDetail?: Accessor<TimelineDetail>
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
@@ -107,12 +102,10 @@ export function createReactiveTimelineProjection(input: {
input.pendingUserMessageIDs?.(),
input.shellToolDefaultOpen?.() ?? false,
input.editToolDefaultOpen?.() ?? false,
(content, showReasoning, detail) =>
(content, showReasoning) =>
content.type === "tool"
? renderable(content, showReasoning, detail)
: (content.type === "text" || (detail ? detail.thinking.placement !== "hidden" : showReasoning)) &&
textVisible().get(content)!(),
input.timelineDetail?.(),
? renderable(content, showReasoning)
: (content.type === "text" || showReasoning) && textVisible().get(content)!(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
@@ -164,7 +157,6 @@ export namespace Timeline {
shellToolDefaultOpen = false,
editToolDefaultOpen = false,
isRenderable = renderable,
detail?: TimelineDetail,
) {
type Turn = {
id: string
@@ -216,68 +208,37 @@ export namespace Timeline {
})
const activeMessageID = turns.findLast((turn) => !pendingUserMessageIDs?.has(turn.id))?.id ?? turns.at(-1)?.id
const visibleNotice = (message: Notice) =>
!detail || detail.notices.placement !== "hidden" || timelineNoticeRequired(message)
const visibleTurns = detail
? turns.filter((turn) => {
if (turn.user) return true
if (turn.shell && (detail.shell.placement !== "hidden" || shellFailed(turn.shell))) return true
return turn.entries.some((entry) =>
entry.type === "notice"
? visibleNotice(entry.message)
: !!entry.message.error ||
!!entry.message.retry ||
entry.message.content.some((content) => isRenderable(content, showReasoning, detail)),
)
})
: turns
const rows: TimelineRow.TimelineRow[] = [
...leading
.filter(visibleNotice)
.map((message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id })),
...visibleTurns.flatMap((turn, index) => {
if (turn.shell)
return [
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
...(!detail || detail.shell.placement !== "hidden" || shellFailed(turn.shell)
? [new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id })]
: []),
...turn.entries.flatMap((entry) =>
entry.type === "notice" && visibleNotice(entry.message)
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
: [],
),
]
return constructMessageRows(
turn.user,
turn.id,
turn.entries,
index,
showReasoning,
status,
turn.id === activeMessageID,
shellToolDefaultOpen,
editToolDefaultOpen,
isRenderable,
detail,
)
}),
]
return {
activeMessageID,
rows: detail
? groupMessages(
rows,
detail,
new Set(
messages
.filter(
(message) => timelineNoticeRequired(message) || (message.type === "shell" && shellFailed(message)),
)
.map((message) => message.id),
),
rows: [
...leading.map(
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
),
...turns.flatMap((turn, index) => {
if (turn.shell)
return [
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
...turn.entries.flatMap((entry) =>
entry.type === "notice"
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
: [],
),
]
return constructMessageRows(
turn.user,
turn.id,
turn.entries,
index,
showReasoning,
status,
turn.id === activeMessageID,
shellToolDefaultOpen,
editToolDefaultOpen,
isRenderable,
)
: rows,
}),
],
}
}
@@ -292,7 +253,6 @@ export namespace Timeline {
shellToolDefaultOpen = false,
editToolDefaultOpen = false,
isRenderable = renderable,
detail?: TimelineDetail,
) {
const rows: TimelineRow.TimelineRow[] = []
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
@@ -301,7 +261,7 @@ export namespace Timeline {
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const lastContent = lastAssistant?.content.at(-1)
const thinking =
(detail ? detail.thinking.placement === "separate" : showReasoning) &&
showReasoning &&
isActive &&
status.type === "busy" &&
lastAssistant?.time.completed === undefined &&
@@ -320,9 +280,7 @@ export namespace Timeline {
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
const refs = messages.flatMap((message, messageIndex) =>
contentEntries(message)
.filter(
(entry) => isRenderable(entry.content, showReasoning, detail) && !(thinking && entry.content === lastContent),
)
.filter((entry) => isRenderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
@@ -330,7 +288,7 @@ export namespace Timeline {
const after = interruptedAt < 0 ? [] : refs.filter((ref) => ref.messageIndex > interruptedAt)
const appendGroups = (items: typeof refs) => {
let offset = 0
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen, detail).forEach((group) => {
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
const tool = group.type !== "part" || items[offset]?.content.type !== "text"
offset += group.type === "part" ? 1 : group.refs.length
rows.push(
@@ -348,8 +306,7 @@ export namespace Timeline {
appendGroups(before)
if (interruptedAt >= 0) {
if (!compaction && detail?.notices.placement !== "hidden")
rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
if (!compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
appendGroups(after)
}
@@ -368,7 +325,6 @@ export namespace Timeline {
assistantSegment.push(entry.message)
return
case "notice":
if (detail?.notices.placement === "hidden" && !timelineNoticeRequired(entry.message)) return
appendAssistantSegment(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
@@ -410,48 +366,6 @@ function isInterrupted(error: SessionMessageAssistant["error"]) {
return error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
}
function shellFailed(message: SessionMessageShell) {
return (
message.status === "timeout" || (message.status === "exited" && message.exit !== undefined && message.exit !== 0)
)
}
function groupMessages(rows: TimelineRow.TimelineRow[], detail: TimelineDetail, required: ReadonlySet<string>) {
return rows.reduce<TimelineRow.TimelineRow[]>((result, row) => {
const previous = result.at(-1)
const current =
((row._tag === "Notice" && detail.notices.placement === "grouped") ||
(row._tag === "Shell" && detail.shell.placement === "grouped")) &&
!required.has(row.messageID)
? new TimelineRow.AssistantPart({
userMessageID: row.userMessageID,
previousAssistantPart: previous?._tag === "AssistantPart",
spacing: previous?._tag === "AssistantPart" ? "tool" : undefined,
group: {
type: "context",
key: `message:${row.messageID}`,
refs: [{ messageID: row.messageID, partID: row.messageID }],
},
})
: row
if (
previous?._tag === "AssistantPart" &&
previous.group.type === "context" &&
current._tag === "AssistantPart" &&
current.group.type === "context" &&
previous.userMessageID === current.userMessageID
) {
result[result.length - 1] = new TimelineRow.AssistantPart({
...previous,
group: { ...previous.group, refs: [...previous.group.refs, ...current.group.refs] },
})
return result
}
result.push(current)
return result
}, [])
}
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
@@ -580,14 +494,11 @@ function groupPartKey(ref: PartRef) {
return `${ref.messageID}:${ref.partID}`
}
function renderable(content: Content, showReasoning: boolean, detail?: TimelineDetail) {
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning")
return (detail ? detail.thinking.placement !== "hidden" : showReasoning) && !!content.text.trim()
if (detail && currentToolFailed(content)) return true
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
if (content.name === "todowrite") return false
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
if (detail && detail[timelineCategory(content)!].placement === "hidden") return false
return true
}
@@ -595,7 +506,6 @@ function groupContent(
items: { messageID: string; partID: string; content: Content }[],
shellToolDefaultOpen: boolean,
editToolDefaultOpen: boolean,
detail?: TimelineDetail,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
@@ -603,7 +513,7 @@ function groupContent(
const current = adjacent
const first = current?.refs[0]
if (!first) return
if (!current.tools && !detail) {
if (!current.tools) {
groups.push(
...current.refs.map((ref) => ({ type: "part" as const, key: `part:${ref.messageID}:${ref.partID}`, ref })),
)
@@ -629,12 +539,9 @@ function groupContent(
shellToolDefaultOpen,
editToolDefaultOpen,
adjacent?.type === "context" && adjacent.tools,
detail,
)
: item.content.type === "reasoning"
? detail && detail.thinking.placement !== "grouped"
? undefined
: "context"
? "context"
: undefined
if (type) {
if (adjacent?.type !== type) flush()
@@ -659,17 +566,8 @@ function toolGroupType(
shellExpanded: boolean,
editExpanded: boolean,
hasContextGroup: boolean,
detail?: TimelineDetail,
) {
if (detail) {
if (currentToolFailed(content) || content.name === "question") return undefined
const category = timelineCategory(content)!
if (detail[category].placement === "grouped") return "context"
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
return undefined
}
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
if (content.name === "question" || hasLoadedFiles(content)) return undefined
if (content.state.status === "error") {
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
if ((content.name === "edit" || content.name === "write" || content.name === "patch") && editExpanded)
@@ -689,6 +587,12 @@ function toolGroupType(
return undefined
}
function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
if (content.name !== "read" || content.state.status !== "completed") return false
const loaded = content.state.metadata?.loaded
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
export function reasoningHeading(text: string): string | undefined {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
@@ -767,5 +671,5 @@ function record(value: unknown): value is Record<string, unknown> {
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
return !!message.description?.trim()
}
@@ -22,8 +22,6 @@ import {
import { AssistantReasoningContent, SessionCompactionMessage } from "../message/message-content"
import type { ContextGroupPart } from "../tools/tool-renderer"
import { SessionRetry } from "../components/session-retry"
import { timelineCategory, type TimelineDetail } from "./detail"
import { currentToolFailed } from "../message/current-tool-state"
import {
createReactiveTimelineProjection,
Timeline,
@@ -51,11 +49,9 @@ export function createSessionTimelineRowRenderer(input: {
reasoningMode: Accessor<ReasoningMode>
shellToolDefaultOpen: Accessor<boolean>
editToolDefaultOpen: Accessor<boolean>
timelineDetail?: Accessor<TimelineDetail>
disclosure: {
value: (key: string) => boolean | undefined
set: (key: string, open: boolean) => void
patchGroupKeys?: Map<string, string>
}
centered?: Accessor<boolean>
padding?: Accessor<string>
@@ -63,21 +59,6 @@ export function createSessionTimelineRowRenderer(input: {
}) {
const i18n = useI18n()
const data = useData()
// Cached timelines retain subgroup identities alongside their disclosure choices.
const patchGroupKeys = input.disclosure.patchGroupKeys ?? new Map<string, string>()
const patchPartKeys = new WeakMap<SessionMessageAssistant["content"][number], string>()
const patchOwners = createMemo(() => {
const owners = new Map<string, string>()
input.projection.rows().forEach((row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => {
const part = `${ref.messageID}:${ref.partID}`
const key = patchGroupKeys.get(part)
if (key && !owners.has(key)) owners.set(key, part)
})
})
return owners
})
const workingTurn = (messageID: string) =>
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
const duration = (messageID: string) => {
@@ -123,35 +104,10 @@ export function createSessionTimelineRowRenderer(input: {
const group = row().group
if (group.type !== "context") return []
const contents = indexGroupContents(group.refs)
const lastAssistant = input.projection.assistantMessagesByParent().get(row().userMessageID)?.at(-1)
return group.refs.flatMap<ContextGroupPart>((ref) => {
const content = contents.get(ref.messageID)?.get(ref.partID)
if (content?.type === "tool") {
patchPartKeys.set(content, `${ref.messageID}:${ref.partID}`)
return [content]
}
if (content?.type === "reasoning")
return [
{
...content,
id: ref.partID,
streaming:
workingTurn(row().userMessageID) &&
input.status().type === "busy" &&
lastAssistant?.id === ref.messageID &&
lastAssistant.time.completed === undefined &&
!lastAssistant.error &&
!lastAssistant.retry &&
lastAssistant.content.at(-1) === content &&
content.time?.completed === undefined,
},
]
const message = input.projection.messageByID().get(ref.messageID)
if (ref.messageID !== ref.partID || !message) return []
if (message.type === "shell")
return [{ type: "shell", id: ref.partID, render: () => <Shell messageID={ref.messageID} grouped /> }]
if (message.type !== "assistant" && message.type !== "user")
return [{ type: "notice", id: ref.partID, render: () => <Notice messageID={ref.messageID} grouped /> }]
if (content?.type === "tool") return [content]
if (content?.type === "reasoning") return [{ ...content, id: ref.partID }]
return []
})
})
@@ -159,30 +115,9 @@ export function createSessionTimelineRowRenderer(input: {
return (
<SessionContextToolGroup
parts={parts()}
patchGroupKey={(tools) => {
const parts = tools.map((tool) => patchPartKeys.get(tool)!)
// After a split, only the subgroup with the earliest surviving member keeps the old anchor.
const key =
parts
.map((part) => patchGroupKeys.get(part))
.find((key) => key !== undefined && parts.includes(patchOwners().get(key)!)) ?? parts[0]!
parts.forEach((part) => patchGroupKeys.set(part, key))
return key
}}
reasoningDefaultOpen={
input.timelineDetail
? input.timelineDetail().thinking.details === "expanded"
: input.reasoningMode() === "full"
}
reasoningDefaultOpen={input.reasoningMode() === "full"}
reasoningOpen={(id) => input.disclosure.value(id)}
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
toolDefaultOpen={(tool) => (input.timelineDetail ? contentDefaultOpen(tool) : false)}
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
fileOpen={(path) =>
input.disclosure.value(`patch:${path}`) ?? input.timelineDetail?.().edit.details === "expanded"
}
onFileOpenChange={(path, open) => input.disclosure.set(`patch:${path}`, open)}
open={input.disclosure.value(key()) === true}
busy={
workingTurn(row().userMessageID) &&
@@ -220,7 +155,6 @@ export function createSessionTimelineRowRenderer(input: {
fileOpen={(path) => {
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
if (open !== undefined) return open
if (input.timelineDetail) return input.timelineDetail().edit.details === "expanded"
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen()
}}
@@ -245,7 +179,9 @@ export function createSessionTimelineRowRenderer(input: {
})
const defaultOpen = createMemo(() => {
const item = content()
return item ? contentDefaultOpen(item) : undefined
if (!item) return undefined
if (item.type === "reasoning") return input.reasoningMode() === "full"
return currentContentDefaultOpen(item, input.shellToolDefaultOpen(), input.editToolDefaultOpen())
})
const disclosureKey = () => (content()?.type === "reasoning" ? ref()!.partID : row().group.key)
return (
@@ -271,17 +207,6 @@ export function createSessionTimelineRowRenderer(input: {
)
}
function contentDefaultOpen(item: SessionMessageAssistant["content"][number]) {
if (input.timelineDetail) {
if (item.type === "tool" && currentToolFailed(item)) return true
const category = timelineCategory(item)
if (category === "shell" || category === "edit" || category === "thinking")
return input.timelineDetail()[category].details === "expanded"
}
if (item.type === "reasoning") return input.reasoningMode() === "full"
return currentContentDefaultOpen(item, input.shellToolDefaultOpen(), input.editToolDefaultOpen())
}
const notice = (message: SessionMessageInfo) => {
if (message.type === "agent-switched")
return {
@@ -346,168 +271,6 @@ export function createSessionTimelineRowRenderer(input: {
</div>
)
function Notice(props: { messageID: string; grouped?: boolean }) {
const inset = () => (props.grouped ? "" : padding())
const message = createMemo(() => input.projection.messageByID().get(props.messageID))
const compaction = createMemo(() => {
const value = message()
return value?.type === "compaction" ? value : undefined
})
const compactionError = createMemo(() => {
const value = compaction()
if (value?.status !== "failed") return ""
return unwrapErrorMessage(value.error.message)
})
const moved = createMemo(() => {
const value = message()
return value?.type === "location-switched" ? value : undefined
})
const model = createMemo(() => {
const value = message()
if (value?.type !== "model-switched") return undefined
const match = data.store.provider?.all?.get(value.model.providerID)
return {
providerID: value.model.providerID,
variant: value.model.variant,
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
model: match?.models?.[value.model.id]?.name ?? value.model.id,
}),
}
})
const content = createMemo(() => {
const value = message()
return value ? notice(value) : undefined
})
return (
<>
<Show when={compaction()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${inset()}`}>
<div data-slot="session-turn-compaction">
<SessionCompactionMessage message={message()} error={compactionError()} />
</div>
</div>
)}
</Show>
<Show
when={moved()}
fallback={
<Show
when={model()}
fallback={
<Show when={content()}>
{(content) => (
<Show
when={content().items?.length}
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
}
>
<div data-slot="session-timeline-notice" class={`w-full py-1 ${inset()}`}>
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
<For each={content().items}>
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
)}
</For>
</div>
</div>
</Show>
)}
</Show>
}
>
{(model) => (
<div data-slot="session-timeline-notice" data-type="model-switched" class={`w-full py-2 ${inset()}`}>
<TimelineSeparator label={model().label} providerID={model().providerID} variant={model().variant} />
</div>
)}
</Show>
}
>
{(message) => (
<div
data-slot="session-timeline-notice"
data-type="location-switched"
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${inset()}`}
>
<Tooltip
appearance="compact"
placement="top"
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
class="shrink-0"
triggerTabIndex={0}
>
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
{i18n.t("ui.sessionTimeline.notice.movedTo")}
</bdi>
</Tooltip>{" "}
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
{message().location.directory}
</bdi>
</div>
)}
</Show>
</>
)
}
function Shell(props: { messageID: string; grouped?: boolean }) {
const message = createMemo(() => {
const value = input.projection.messageByID().get(props.messageID)
return value?.type === "shell" ? value : undefined
})
const defaultOpen = createMemo(() => {
if (!input.timelineDetail) return input.shellToolDefaultOpen()
const value = message()
return (
input.timelineDetail().shell.details === "expanded" ||
value?.status === "timeout" ||
(value?.status === "exited" && value.exit !== undefined && value.exit !== 0)
)
})
return (
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${props.grouped ? "" : padding()}`}>
<SessionShellMessage
message={message()}
defaultOpen={defaultOpen()}
open={input.disclosure.value(message().id) ?? (input.timelineDetail ? defaultOpen() : undefined)}
onOpenChange={(open) => input.disclosure.set(message().id, open)}
/>
</div>
)}
</Show>
)
}
const render = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
if (row()._tag === "TurnGap") return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
if (row()._tag === "UserMessage") {
@@ -552,9 +315,24 @@ export function createSessionTimelineRowRenderer(input: {
if (value._tag !== "Shell") throw new Error("Expected a shell timeline row")
return value
}
const message = createMemo(() => {
const value = input.projection.messageByID().get(current().messageID)
return value?.type === "shell" ? value : undefined
})
return (
<Frame row={current()}>
<Shell messageID={current().messageID} />
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<SessionShellMessage
message={message()}
defaultOpen={input.shellToolDefaultOpen()}
open={input.disclosure.value(message().id)}
onOpenChange={(open) => input.disclosure.set(message().id, open)}
/>
</div>
)}
</Show>
</Frame>
)
}
@@ -564,9 +342,140 @@ export function createSessionTimelineRowRenderer(input: {
if (value._tag !== "Notice") throw new Error("Expected a notice timeline row")
return value
}
const message = createMemo(() => input.projection.messageByID().get(current().messageID))
const compaction = createMemo(() => {
const value = message()
return value?.type === "compaction" ? value : undefined
})
const compactionError = createMemo(() => {
const value = compaction()
if (value?.status !== "failed") return ""
return unwrapErrorMessage(value.error.message)
})
const moved = createMemo(() => {
const value = message()
return value?.type === "location-switched" ? value : undefined
})
const model = createMemo(() => {
const value = message()
if (value?.type !== "model-switched") return undefined
const match = data.store.provider?.all?.get(value.model.providerID)
return {
providerID: value.model.providerID,
variant: value.model.variant,
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
model: match?.models?.[value.model.id]?.name ?? value.model.id,
}),
}
})
const content = createMemo(() => {
const value = message()
return value ? notice(value) : undefined
})
return (
<Frame row={current()}>
<Notice messageID={current().messageID} />
<Show when={compaction()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-compaction">
<SessionCompactionMessage message={message()} error={compactionError()} />
</div>
</div>
)}
</Show>
<Show
when={moved()}
fallback={
<Show
when={model()}
fallback={
<Show when={content()}>
{(content) => (
<Show
when={content().items?.length}
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full truncate pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
}
>
<div data-slot="session-timeline-notice" class={`w-full py-1 ${padding()}`}>
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
<For each={content().items}>
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
)}
</For>
</div>
</div>
</Show>
)}
</Show>
}
>
{(model) => (
<div
data-slot="session-timeline-notice"
data-type="model-switched"
class={`w-full py-2 ${padding()}`}
>
<TimelineSeparator
label={model().label}
providerID={model().providerID}
variant={model().variant}
/>
</div>
)}
</Show>
}
>
{(message) => (
<div
data-slot="session-timeline-notice"
data-type="location-switched"
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
>
<Tooltip
appearance="compact"
placement="top"
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
class="shrink-0"
triggerTabIndex={0}
>
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
{i18n.t("ui.sessionTimeline.notice.movedTo")}
</bdi>
</Tooltip>{" "}
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
{message().location.directory}
</bdi>
</div>
)}
</Show>
</Frame>
)
}
@@ -627,15 +536,8 @@ export function createSessionTimelineRowRenderer(input: {
id={current().ref.partID}
content={content()}
streaming
defaultOpen={
input.timelineDetail
? input.timelineDetail().thinking.details === "expanded"
: input.reasoningMode() === "full"
}
open={
input.disclosure.value(current().ref.partID) ??
(input.timelineDetail ? input.timelineDetail().thinking.details === "expanded" : undefined)
}
defaultOpen={input.reasoningMode() === "full"}
open={input.disclosure.value(current().ref.partID)}
onOpenChange={(open) => input.disclosure.set(current().ref.partID, open)}
onContentRendered={onSizeChange}
/>
+35 -78
View File
@@ -44,11 +44,9 @@ import type {
} from "@opencode-ai/client/promise"
import {
currentToolError,
currentToolHasLoadedFiles,
currentToolInput,
currentToolMetadata,
currentToolOutput,
executeToolFailed,
} from "../message/current-tool-state"
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
@@ -482,10 +480,7 @@ function ExaOutput(props: { output?: string }) {
)
}
export type ContextGroupPart =
| SessionMessageAssistantTool
| (SessionMessageAssistantReasoning & { id: string; streaming?: boolean })
| { type: "notice" | "shell"; id: string; render: () => JSX.Element }
export type ContextGroupPart = SessionMessageAssistantTool | (SessionMessageAssistantReasoning & { id: string })
export function CurrentContextToolGroup(props: {
parts: ContextGroupPart[]
@@ -496,12 +491,6 @@ export function CurrentContextToolGroup(props: {
reasoningDefaultOpen?: boolean
reasoningOpen?: (id: string) => boolean | undefined
onReasoningOpenChange?: (id: string, open: boolean) => void
toolDefaultOpen?: (tool: SessionMessageAssistantTool) => boolean | undefined
toolOpen?: (id: string) => boolean | undefined
onToolOpenChange?: (id: string, open: boolean) => void
fileOpen?: (path: string) => boolean | undefined
onFileOpenChange?: (path: string, open: boolean) => void
patchGroupKey?: (tools: SessionMessageAssistantTool[]) => string
}) {
const i18n = useI18n()
const tools = createMemo(() => props.parts.filter((part) => part.type === "tool"))
@@ -510,16 +499,14 @@ export function CurrentContextToolGroup(props: {
)
const names = createMemo(() =>
[
...props.parts.reduce((counts, part) => {
if (part.type !== "tool" && part.type !== "shell") return counts
...tools().reduce((counts, tool) => {
const input = currentToolInput(tool)
const name =
part.type !== "tool"
? i18n.t("ui.tool.shell")
: part.name === "skill"
? i18n.t("ui.tool.skill")
: part.name === "subagent"
? i18n.t("ui.tool.agent.default")
: getToolInfo(part.name, currentToolInput(part), currentToolMetadata(part)).title
tool.name === "skill"
? i18n.t("ui.tool.skill")
: tool.name === "subagent"
? i18n.t("ui.tool.agent.default")
: getToolInfo(tool.name, input, currentToolMetadata(tool)).title
counts.set(name, (counts.get(name) ?? 0) + 1)
return counts
}, new Map<string, number>()),
@@ -528,22 +515,15 @@ export function CurrentContextToolGroup(props: {
.join(", "),
)
const label = createMemo(() => {
const notices = props.parts.filter((part) => part.type === "notice").length
if (!names() && !notices) {
const title = i18n.t("ui.messagePart.context.reasoning")
return { text: title, title, before: "", after: "" }
}
const title = [names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
.filter(Boolean)
.join(", ")
const title = names()
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
const index = text.indexOf(title)
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
})
const items = createMemo(() =>
props.parts.reduce<(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]>(
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
(groups, tool) => {
if (tool.type !== "tool") {
if (tool.type === "reasoning") {
groups.push(tool)
return groups
}
@@ -576,15 +556,6 @@ export function CurrentContextToolGroup(props: {
[],
),
)
const patchKeys = createMemo(() => {
const keys = new Map<SessionMessageAssistantTool, string>()
items().forEach((item) => {
if (!Array.isArray(item) || item[0]?.name !== "patch" || item[0].state.status === "error") return
const key = props.patchGroupKey?.(item) ?? item[0].id
item.forEach((tool) => keys.set(tool, key))
})
return keys
})
const change = (open: boolean) => {
props.onOpenChange(open)
props.onSizeChange?.()
@@ -623,30 +594,19 @@ export function CurrentContextToolGroup(props: {
})
const reasoning = createMemo(() => {
const value = item()
return !Array.isArray(value) && value.type === "reasoning" ? value : undefined
})
const callback = createMemo(() => {
const value = item()
return !Array.isArray(value) && (value.type === "notice" || value.type === "shell") ? value : undefined
return Array.isArray(value) ? undefined : value
})
return (
<Show
when={group()}
fallback={
<Show
when={reasoning()}
fallback={
<Show when={callback()}>
{(part) => <div data-slot="context-tool-group-item">{part().render()}</div>}
</Show>
}
>
<Show when={reasoning()}>
{(part) => (
<div data-slot="context-tool-group-item">
<AssistantReasoningContent
id={part().id}
content={part()}
streaming={part().streaming ?? false}
streaming={false}
defaultOpen={props.reasoningDefaultOpen}
open={props.reasoningOpen?.(part().id)}
onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)}
@@ -676,8 +636,7 @@ export function CurrentContextToolGroup(props: {
when={
tool().state.status !== "error" &&
["read", "glob", "grep", "list"].includes(tool().name) &&
!(tool().name === "read" && readImagePath(currentToolInput(tool()))) &&
!currentToolHasLoadedFiles(tool())
!(tool().name === "read" && readImagePath(currentToolInput(tool())))
}
fallback={
<Show
@@ -694,28 +653,14 @@ export function CurrentContextToolGroup(props: {
output={currentToolOutput(tool())}
error={currentToolError(tool())}
status={tool().state.status}
defaultOpen={props.toolDefaultOpen?.(tool()) ?? false}
open={props.toolOpen?.(tool().id) ?? props.toolDefaultOpen?.(tool())}
onOpenChange={(open) => props.onToolOpenChange?.(tool().id, open)}
defaultOpen={false}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
}
>
<CurrentFileToolGroup
tools={group()}
fileOpen={
props.fileOpen &&
((path) => props.fileOpen?.(`${patchKeys().get(tool())}:${path}`))
}
onFileOpenChange={
props.onFileOpenChange &&
((path, open) =>
props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open))
}
onSizeChange={props.onSizeChange}
/>
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
</Show>
}
>
@@ -1099,7 +1044,19 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
function toolDisplayError(props: ToolProps & { error?: string }, fallback: string) {
if (props.status === "error") return props.error
if (props.tool !== "execute") return undefined
if (!executeToolFailed(props.metadata)) return undefined
const calls = props.metadata.toolCalls
const failed =
props.metadata.error === true ||
(Array.isArray(calls) &&
calls.some(
(call) =>
call !== null &&
typeof call === "object" &&
!Array.isArray(call) &&
"status" in call &&
call.status === "error",
))
if (!failed) return undefined
if (typeof props.output === "string" && props.output) return props.output
return fallback
}
@@ -1441,13 +1398,13 @@ ToolRegistry.register({
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>
</div>
<Show when={clickable()}>
<div data-component="task-tool-action">
<Icon name="chevron-right" size="small" />
</div>
</Show>
</div>
</div>
<Show when={clickable()}>
<div data-component="task-tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div>
)
@@ -1,6 +1,5 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { Spinner } from "../../component/spinner"
@@ -38,7 +37,7 @@ export function PluginsDialog(props: {
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("large"))
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
const updating = (entry: Entry) =>
pending().includes(entry.key) ||
@@ -168,6 +167,13 @@ export function PluginsDialog(props: {
.check({ location: props.context.location ?? props.context.data.location.default() })
.then((result) => {
mutate(result.data)
const count = result.data.filter(
(plugin) => plugin.source.type === "package" && plugin.source.outdated === true,
).length
props.context.ui.toast.show({
variant: count ? "info" : "success",
message: count ? `${count} plugin update${count === 1 ? "" : "s"} available` : "All plugins are up to date",
})
})
.catch((cause) => {
props.context.ui.toast.show({
@@ -212,7 +218,19 @@ export function PluginsDialog(props: {
}}
actions={[
{
title: checking() ? "checking for updates" : "check for updates",
title: toggleTitle(),
command: "plugins.toggle",
hidden: !focusedTui(),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
{
title: "update",
command: "dialog.plugins.update",
hidden: !updatable(focusedEntry()),
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
},
{
title: checking() ? "checking" : "check",
command: "dialog.plugins.check",
selection: "none",
hidden: !entries().some(
@@ -221,20 +239,6 @@ export function PluginsDialog(props: {
disabled: checking(),
onTrigger: check,
},
{
title: toggleTitle(),
command: "plugins.toggle",
side: "right",
hidden: !focusedTui(),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
{
title: "update",
command: "dialog.plugins.update",
side: "right",
hidden: !updatable(focusedEntry()),
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
@@ -258,7 +262,7 @@ export function PluginsDialog(props: {
context={`Plugin: ${label(entry(), props.context)}\nStatus: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("large")
dialog.setSize("medium")
}}
/>
)}
@@ -283,14 +287,6 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
return plugin.source.type
}
function isLocal(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.source.type === "local"
const target = entry.target
return (
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
)
}
function status(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.state.status
return entry.status
@@ -303,7 +299,6 @@ function outdated(entry: Entry) {
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(isLocal(entry) ? ["local"] : []),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),
@@ -93,7 +93,7 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
await app.waitForFrame((frame) => frame.includes("Plugins"))
return { app, requests, toasts }
}
@@ -102,16 +102,16 @@ test("checking for updates refreshes the inventory and reveals the update action
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
try {
// The update action starts hidden: triggering it before a check issues no request.
fixture.app.mockInput.pressKey("u", { ctrl: true })
await fixture.app.flush()
expect(fixture.requests).toEqual([])
const initial = await fixture.app.waitForFrame((frame) => frame.includes("dadba13"))
expect(initial).toContain("check ctrl+r")
expect(initial).not.toContain("update available")
expect(initial).not.toContain("ctrl+u")
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.requests.length === 1)
const checked = await fixture.app.waitForFrame((frame) => frame.includes("update available"))
expect(checked).toContain("ctrl+u")
expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }])
// Let the check response apply before triggering the now-enabled update.
await fixture.app.flush()
expect(fixture.toasts).toEqual([{ variant: "info", message: "1 plugin update available" }])
fixture.app.mockInput.pressKey("u", { ctrl: true })
await fixture.app.waitFor(() => fixture.requests.length === 2)
@@ -126,12 +126,12 @@ test("checking for updates reports an up-to-date inventory", async () => {
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(false)] })
try {
await fixture.app.waitForFrame((frame) => frame.includes("dadba13"))
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.requests.length === 1)
await fixture.app.flush()
await fixture.app.waitFor(() => fixture.toasts.length === 1)
expect(fixture.toasts).toEqual([{ variant: "success", message: "All plugins are up to date" }])
expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }])
expect(fixture.toasts).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
@@ -148,6 +148,9 @@ test("the check action stays hidden without package plugins", async () => {
const fixture = await renderPlugins(tmp.path, { list: [local], check: [] })
try {
const frame = await fixture.app.waitForFrame((frame) => frame.includes("local.plugin"))
expect(frame).not.toContain("ctrl+r")
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.flush()
expect(fixture.requests).toEqual([])
-2
View File
@@ -181,8 +181,6 @@ export function Select<T>(props: SelectProps<T>) {
>
<Trigger
as="div"
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
data-component="select-v2"
data-appearance="inline"
data-invalid={local.invalid ? "" : undefined}
-3
View File
@@ -108,9 +108,6 @@ const source = {
"ui.messagePart.context.search.other": "{{count}} searches",
"ui.messagePart.context.list.one": "{{count}} list",
"ui.messagePart.context.list.other": "{{count}} lists",
"ui.messagePart.context.notice.one": "{{count}} notice",
"ui.messagePart.context.notice.other": "{{count}} notices",
"ui.messagePart.context.reasoning": "Reasoning",
"ui.messagePart.context.match.one": "({{count}} match)",
"ui.messagePart.context.match.other": "({{count}} matches)",
"ui.messagePart.tools.used": "Used {{tools}}",
@@ -5,7 +5,6 @@ const names = [
"archive",
"arrow-left",
"arrow-right",
"arrow-up-right",
"branch",
"check",
"chevron-down",
+7 -7
View File
@@ -148,10 +148,6 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M4.14908 11.0081H1.76282V1.51758H9.1038V2.55588M14.2225 4.99681H6.75397V14.4873H14.2225V4.99681Z" stroke="currentColor"/>`,
},
"arrow-up-right": {
viewBox: "0 0 16 16",
body: `<path d="M11 9.56V5H6.44M11 5L5 11" stroke="currentColor"/>`,
},
"outline-square-arrow": {
viewBox: "0 0 16 16",
body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
@@ -194,8 +190,12 @@ function getIcon(name: IconName) {
function ensureSprite() {
if (spriteInserted) return
if (typeof document === "undefined") return
// Hot reload preserves the DOM sprite, but its symbols may be from an older module.
const svg = document.getElementById(spriteID) ?? document.createElementNS("http://www.w3.org/2000/svg", "svg")
if (document.getElementById(spriteID)) {
spriteInserted = true
return
}
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
svg.id = spriteID
svg.setAttribute("aria-hidden", "true")
svg.setAttribute("width", "0")
@@ -205,7 +205,7 @@ function ensureSprite() {
svg.innerHTML = Array.from(new Set<IconName>([...Object.keys(additionalIcons), ...Object.keys(icons)] as IconName[]))
.map((name) => `<symbol id="${symbol(name)}" viewBox="${getIcon(name).viewBox}">${getIcon(name).body}</symbol>`)
.join("")
if (!svg.isConnected) document.body.insertBefore(svg, document.body.firstChild)
document.body.insertBefore(svg, document.body.firstChild)
spriteInserted = true
}
@@ -644,8 +644,7 @@ effect: (ctx) =>
}),
```
Add or remove local and Git references, then reload after external state changes. `get(name)` returns the current configured
source, or `undefined` when the name is absent.
Add or remove local and Git references, then reload after external state changes.
```ts
effect: (ctx) =>
@@ -654,7 +653,6 @@ effect: (ctx) =>
yield* reference.transform((editor) => {
editor.add("handbook", { type: "local", path: "/workspace/docs/handbook" })
editor.add("standards", { type: "git", repository: "https://github.com/acme/standards", branch: "main" })
const handbook = editor.get("handbook")
editor.remove("legacy")
})
yield* reference.reload()
@@ -669,7 +667,6 @@ interface ReferenceEditor {
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
}
interface ReferenceDomain extends ReferenceApi<unknown> {
@@ -750,8 +747,7 @@ effect: (ctx) =>
}),
```
Transform and reload skills. Use the re-exported Effect schema constructors for branded values. `get(id)` returns the
current editor entry, or `undefined` when the skill is absent.
Transform and reload skills. Use the re-exported Effect schema constructors for branded values.
```ts
effect: (ctx) =>
@@ -765,7 +761,6 @@ effect: (ctx) =>
location: "/workspace/.opencode/skills/review.md",
content: "Review the current changes for correctness and missing tests.",
}))
const review = editor.get("review")
editor.update("review", (item) => (item.autoinvoke = true))
editor.remove("legacy")
})
@@ -778,7 +773,6 @@ Schema: [`Skill.Info`](/api#schema-Skill.Info).
```ts
interface SkillEditor {
list(): readonly Types.DeepMutable<Skill.Info>[]
get(id: string): Types.DeepMutable<Skill.Info> | undefined
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
@@ -596,15 +596,13 @@ Read the references available at a location.
const references = await ctx.reference.list()
```
Register a transform to inspect, add, or remove local and Git references. `get(name)` returns the current configured source,
or `undefined` when the name is absent.
Register a transform to inspect, add, or remove local and Git references.
```ts
await ctx.reference.transform((editor) => {
const references = editor.list()
editor.add("handbook", { type: "local", path: "/workspace/docs/handbook" })
editor.add("standards", { type: "git", repository: "https://github.com/acme/standards", branch: "main" })
const handbook = editor.get("handbook")
editor.remove("legacy")
})
```
@@ -629,7 +627,6 @@ interface ReferenceContext {
interface ReferenceEditor {
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
}
@@ -720,8 +717,7 @@ Read the skills available at a location.
const skills = await ctx.skill.list()
```
Register a transform to inspect, add, update, or remove skills. `get(id)` returns the current editor entry, or `undefined`
when the skill is absent.
Register a transform to inspect, add, update, or remove skills.
```ts
await ctx.skill.transform((editor) => {
@@ -733,7 +729,6 @@ await ctx.skill.transform((editor) => {
location: "/workspace/.opencode/skills/review.md",
content: "Review the current changes for correctness and missing tests.",
})
const review = editor.get("review")
editor.update("review", (skill) => {
skill.autoinvoke = true
})
@@ -760,7 +755,6 @@ interface SkillContext {
interface SkillEditor {
list(): readonly SkillInfo[]
get(id: string): SkillInfo | undefined
add(skill: SkillInfo): void
update(id: string, update: (skill: SkillInfo) => void): void
remove(id: string): void