Compare commits

...
26 Commits
Author SHA1 Message Date
opencode-agent[bot]andHona 887f319769 fix(desktop): restore compact Windows channel badge (#47016)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 20:19:03 +10:00
24f6cb51c8 fix(core): watch new config files and directories (#46925)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-03 15:38:28 +05:30
opencode-agent[bot]andvimtor d2bec894bf fix(app): identify desktop in Console device auth (#47001)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-03 11:50:45 +02:00
opencode-agent[bot] d7009156a6 chore: update nix node_modules hashes 2026-09-03 08:56:58 +00:00
4cfca7ab68 fix(app): restore terminal application mouse handling (#45140)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 16:39:27 +08:00
Luke Parker ae7d9f029d fix(session-ui): preserve configured groups on tool failure (#46998) 2026-09-03 07:30:50 +00:00
Luke Parker 9acdb0be18 fix(session-ui): use standard Used labels for thoughts (#46995) 2026-09-03 07:10:56 +00:00
b42555cf01 fix: reuse current location for directory browsing (#46970)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 16:45:17 +10:00
Luke Parker cfe9f13963 fix(ui): capitalize notices in used group (#46992) 2026-09-03 06:43:01 +00:00
David Hill 48f246695e fix(app): align new session icon (#46983) 2026-09-03 16:02:01 +10:00
Brendan Allan 4f6060ad94 feat(app): route settings and refine shell styling (#46984) 2026-09-03 13:51:07 +08:00
opencode-agent[bot]andHona bf6ec61a74 fix(app): remove background running indicator (#46972)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 15:40:55 +10:00
Dax Raad cf298f3409 fix(cli): use artifact as client identity 2026-09-03 00:11:34 -04:00
Dax Raad 0089ac9b02 fix(cli): align artifact user agent format 2026-09-03 00:09:48 -04:00
Kit Langton 4680a4aa6f refactor(core): reconcile current watcher policy (#46949) 2026-09-02 23:15:25 -04:00
Luke Parker 88e4ab5735 feat(app): add timeline detail presets and placement controls (#46717) 2026-09-03 13:10:57 +10:00
Kit Langton efefd90443 feat(plugin): add reference editor lookup 2026-09-03 01:59:36 +00:00
Dax Raad 2b87169cc1 feat(tui): polish plugin dialog sizing, actions, and local footer 2026-09-02 21:55:48 -04:00
Kit Langton 962c26bdf2 feat(plugin): add skill editor lookup 2026-09-02 21:55:00 -04:00
David Hill 22de01e84f fix(app): animate subagent card chevron (#46893) 2026-09-03 09:47:53 +08:00
Aiden Cline 8565cb52a1 chore(ai): clean up responses item id comments (#46951) 2026-09-02 20:44:30 -05:00
Kit Langton 050398f51f fix(core): preserve provider identity in catalog updates 2026-09-03 01:44:12 +00:00
Kit Langton 5f1d74fd3f fix(plugin): export Promise ToolEditor 2026-09-03 01:40:03 +00:00
Aiden Cline d9c85d8d95 refactor(ai): resolve responses item ids once at the stream boundary (#46885) 2026-09-02 20:31:00 -05:00
Kit Langton 1c77b1c920 refactor(core): remove unused repository cache success timestamp (#46942) 2026-09-02 21:17:59 -04:00
Kit Langton 27f838f249 fix(client): refresh references for the updated location (#46935) 2026-09-02 21:12:52 -04:00
119 changed files with 3703 additions and 1519 deletions
+1
View File
@@ -1046,6 +1046,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
}
}
+1
View File
@@ -177,6 +177,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
+84 -86
View File
@@ -304,6 +304,11 @@ 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,
@@ -316,6 +321,7 @@ 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
@@ -395,6 +401,7 @@ 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
@@ -416,14 +423,13 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
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>>
}
@@ -875,9 +881,6 @@ 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"] = []
@@ -921,9 +924,34 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
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]
@@ -997,7 +1025,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1010,18 +1038,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `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: Event): StepResult => {
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
if (state.message !== undefined && state.message.id !== item.id) 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 !== itemID)
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
@@ -1038,14 +1066,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
},
},
events,
]
}
if (item && isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1065,18 +1093,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
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
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 })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1148,13 +1174,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
item: NormalizedEvent["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1171,7 +1197,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message" && item.id !== undefined) {
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
@@ -1204,36 +1230,23 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
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,
})
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 result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.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 !== undefined || finished.length === 0
registered || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1244,13 +1257,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, callID]),
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
if (item.type === "reasoning") {
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 : []
@@ -1334,21 +1347,17 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
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
// 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
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1415,12 +1424,9 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
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
// 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) => {
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(
@@ -1460,20 +1466,16 @@ export const step = (state: ParserState, input: Event) => {
? 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 &&
isReasoningItem(event.item) &&
event.item?.type === "reasoning" &&
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 && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
@@ -1483,11 +1485,7 @@ export const step = (state: ParserState, input: Event) => {
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") {
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.output_item.done") 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")
@@ -1537,7 +1535,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
terminal,
},
})
@@ -201,12 +201,11 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -3,8 +3,7 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
export type Item = OpenResponses.OutputItem & {
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -27,8 +26,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const onDone: (
state: OpenResponses.ParserState,
+2 -1
View File
@@ -69,7 +69,8 @@ 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, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
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)
+8 -5
View File
@@ -125,12 +125,15 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("rejects terminal checkpoints missing an id", () =>
).effect("mints an id for terminal checkpoints that omit one", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
const response = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
)
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")
}),
)
@@ -329,69 +329,126 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
;[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 } },
// 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",
},
])
}),
)
},
{
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(`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("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("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -436,21 +493,6 @@ 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(
@@ -500,14 +542,15 @@ 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", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", id: "fc_1", 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" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,23 +586,21 @@ 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(
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.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.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -610,43 +608,6 @@ 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 an item-id-less tool call with only the new tool output", () =>
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,6 +485,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2129,47 +2130,6 @@ 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(
@@ -2389,7 +2349,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2931,14 +2891,10 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
// 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"}' },
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3793,43 +3749,6 @@ 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(
@@ -4017,7 +3936,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +3951,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -4053,37 +3973,6 @@ 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,6 +11,7 @@ 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"
@@ -122,7 +123,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
settings?: Record<string, boolean | TimelineDetail>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -92,6 +92,8 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
@@ -114,6 +116,8 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
@@ -67,6 +67,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
@@ -87,6 +88,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -30,9 +30,11 @@ test("session settings use the remote server context", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
@@ -66,6 +68,15 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
})
@@ -67,6 +67,12 @@ 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,
@@ -95,9 +101,7 @@ 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({
@@ -221,7 +225,8 @@ 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,4 +1,5 @@
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]) {
@@ -16,6 +17,9 @@ 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,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -36,7 +37,9 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -68,7 +71,9 @@ 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: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -100,6 +105,7 @@ 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,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -23,20 +24,23 @@ 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, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
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))), 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 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 expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -46,6 +50,9 @@ 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" }))
@@ -95,7 +102,9 @@ 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: { shellToolPartsExpanded: false },
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -110,7 +119,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -118,7 +127,13 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -141,28 +156,11 @@ 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"))
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(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
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()
})
}
@@ -172,6 +170,9 @@ 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,5 +1,6 @@
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,
@@ -49,6 +50,9 @@ 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 } },
@@ -84,7 +88,12 @@ 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, { sessionMessages: [user, assistant(true)] })
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
await timeline.send(
compactionStarted({
@@ -161,7 +170,12 @@ 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, { sessionMessages: [user, assistant(false, true)] })
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
@@ -198,6 +212,9 @@ 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" } },
@@ -213,6 +230,7 @@ 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,
{
@@ -281,6 +299,7 @@ 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,
{
@@ -369,6 +388,13 @@ 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")
@@ -376,6 +402,7 @@ 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,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -14,7 +15,9 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
messages: [
userMessage(),
assistantMessage([
@@ -34,6 +37,7 @@ 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) => {
@@ -110,8 +114,11 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -127,6 +134,7 @@ 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,134 +5,48 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
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")
})
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: "Used 1 Thought", 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()
})
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
@@ -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"), 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 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 expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -161,12 +161,14 @@ test("keeps failed search calls and their error cards inside the collapsed stack
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
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 expect(summary).toHaveAttribute("aria-expanded", "false")
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.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
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(
@@ -180,6 +182,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -74,21 +75,29 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
const timeline = await setupTimeline(page, {
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: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
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 timeline.settle()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -106,8 +115,6 @@ 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,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -7,11 +8,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("transitions shell and question through running error outcomes", async ({ page }) => {
test("keeps shell and question failures in their Used group", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -23,30 +24,46 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
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 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,
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
)
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()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -77,9 +94,12 @@ test("preserves surviving grouped patch state when its first patch fails", async
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
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) => {
@@ -99,23 +119,20 @@ test("preserves surviving grouped patch state when its first patch fails", async
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
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([
@@ -148,7 +165,10 @@ 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, { messages: [userMessage(), assistantMessage(parts)] })
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -19,6 +20,9 @@ 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)
@@ -51,7 +55,9 @@ 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: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -89,13 +95,8 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
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(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
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")
@@ -259,6 +260,7 @@ 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`,
@@ -32,6 +32,24 @@ test.beforeEach(async ({ page }) => {
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
await expect(page).toHaveURL("/settings")
await expect(home).toHaveAttribute("aria-pressed", "false")
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(home).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
await expect(home).toHaveAttribute("aria-pressed", "false")
await home.click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -29,7 +29,7 @@ test("new session tab matches neighboring session widths", async ({ page }, test
await page.goto(href)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
@@ -194,7 +194,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+2 -1
View File
@@ -2,6 +2,7 @@
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
+1 -1
View File
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
@@ -1,5 +1,6 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
+34
View File
@@ -951,6 +951,40 @@ 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.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,8 +230,6 @@ 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)
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Ghostty, Terminal } from "ghostty-web"
describe("terminal application mouse reporting", () => {
let container: HTMLDivElement
let terminal: Terminal
let canvas: HTMLCanvasElement
let data: string[]
beforeEach(async () => {
container = document.createElement("div")
document.body.appendChild(container)
terminal = new Terminal({ cols: 80, rows: 24, ghostty: await Ghostty.load() })
terminal.open(container)
terminal.write("\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1015h\x1b[?1006h")
canvas = container.querySelector("canvas")!
data = []
terminal.onData((value) => data.push(value))
})
afterEach(() => {
terminal.dispose()
container.remove()
})
const wheel = (deltaX: number, deltaY: number) => {
const event = new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })
Object.defineProperties(event, { clientX: { value: 40 }, clientY: { value: 30 } })
canvas.dispatchEvent(event)
}
test("reports wheel input with its terminal coordinates instead of arrow keys", () => {
wheel(0, -100)
expect(data).toEqual(["\x1b[<64;6;3M"])
})
test("does not discard small trackpad deltas", () => {
wheel(0, -1)
expect(data).toEqual(["\x1b[<64;6;3M"])
})
test("reports horizontal scrolling", () => {
wheel(100, 0)
expect(data).toEqual(["\x1b[<67;6;3M"])
})
test("distinguishes pointer movement from a left-button drag", () => {
canvas.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<35;6;3M"])
})
test("encodes Alt-modified clicks", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { altKey: true, clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<8;6;3M"])
})
test("leaves application clicks available for process selection", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { clientX: 40, clientY: 30, bubbles: true }))
canvas.dispatchEvent(new MouseEvent("mouseup", { clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<0;6;3M", "\x1b[<0;6;3m"])
})
test("preserves Shift as the terminal selection override", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { shiftKey: true, clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual([])
})
})
@@ -3,6 +3,7 @@ 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 } },
@@ -63,6 +64,7 @@ describe("visibleTimelineMessages", () => {
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
timelineDetail: () => timelinePresets[2].value,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
@@ -21,6 +21,7 @@ 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 => {
@@ -101,12 +102,32 @@ 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: settings.general.reasoningMode,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
reasoningMode,
shellToolDefaultOpen: shellToolPartsExpanded,
editToolDefaultOpen: editToolPartsExpanded,
timelineDetail,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -235,9 +256,10 @@ export function createTimelineController(input: { session: TimelineSessionSource
childTitle,
showHeader,
projection,
reasoningMode: settings.general.reasoningMode,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
timelineDetail,
reasoningMode,
shellToolPartsExpanded,
editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
@@ -420,6 +420,7 @@ 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,6 +528,7 @@ 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,
@@ -21,7 +21,7 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
@@ -33,7 +33,15 @@ 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> }>()
const cache = new Map<
string,
{
measurements: VirtualItem[]
toolOpen: Record<string, boolean | undefined>
patchGroupKeys: Map<string, string>
presentationKey?: string
}
>()
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
@@ -42,6 +50,7 @@ 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. */
@@ -77,11 +86,20 @@ export function createTimelineVirtualizer(input: Input) {
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const entry = cache.get(ownerSessionKey)
const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined
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
@@ -189,7 +207,8 @@ export function createTimelineVirtualizer(input: Input) {
},
scrollEndThreshold: 80,
get scrollMargin() {
return topOffset()
// Empty projections still need the bottom spacer for running status.
return rows().length > 0 ? topOffset() : 0
},
paddingEnd: 64,
get rangeExtractor() {
@@ -540,15 +559,13 @@ export function createTimelineVirtualizer(input: Input) {
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<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
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>
</div>
</ScrollView>
</div>
@@ -557,7 +574,12 @@ export function createTimelineVirtualizer(input: Input) {
onCleanup(() => {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
cache.set(ownerSessionKey, {
measurements: virtualizer.takeSnapshot(),
toolOpen: { ...toolOpen },
patchGroupKeys,
presentationKey: input.presentationKey?.(),
})
while (cache.size > 16) cache.delete(cache.keys().next().value!)
coldPending = false
contentObserver?.disconnect()
@@ -569,6 +591,7 @@ export function createTimelineVirtualizer(input: Input) {
return {
disclosure: {
patchGroupKeys,
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
+13 -55
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 type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { TimelineDetailControl } from "@/settings/timeline-detail"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
@@ -184,34 +184,6 @@ 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 (
@@ -360,8 +332,6 @@ export const SettingsGeneral: Component<{
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<ReasoningModeSetting />
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -378,30 +348,6 @@ 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")}
@@ -585,6 +531,18 @@ 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>
+20 -53
View File
@@ -1,5 +1,6 @@
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,
@@ -16,54 +17,19 @@ const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
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)
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" },
})
expect(settings.appearance.fontSize).toBe(16)
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)
expect(decode(encode(settings))).toEqual(settings)
})
})
@@ -71,13 +37,16 @@ describe("settings schema", () => {
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, 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.reasoningMode).toBe("full")
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
@@ -92,9 +61,7 @@ describe("settings schema", () => {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -144,7 +111,7 @@ describe("settings schema", () => {
showTerminal: true,
autoSave: false,
releaseNotes: true,
reasoningMode: "compact",
timelineDetail: timelinePresets[2].value,
followUpBehavior: "steer",
})
expect(settings.appearance).toEqual({
+89 -31
View File
@@ -1,8 +1,8 @@
import { reconcile } from "solid-js/store"
import { reconcile, unwrap } 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 type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
@@ -68,7 +68,10 @@ export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
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 generalSchema = Persistence.struct({
autoSave: Schema.Boolean,
@@ -79,9 +82,14 @@ const generalSchema = Persistence.struct({
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
reasoningMode: reasoningModeSchema,
shellToolPartsExpanded: Schema.Boolean,
editToolPartsExpanded: Schema.Boolean,
timelineDetail: Persistence.struct({
shell: activitySchema,
edit: activitySchema,
thinking: activitySchema,
subagents: placementOnlySchema,
notices: placementOnlySchema,
tools: placementOnlySchema,
}),
showCustomAgents: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
mobileDiffWrap: Schema.Boolean,
@@ -134,25 +142,91 @@ 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({
reasoningMode: Schema.optional(Schema.Unknown),
// 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))),
),
showReasoningSummaries: Persistence.optional(Schema.Boolean),
shellToolPartsExpanded: Persistence.optional(Schema.Boolean),
editToolPartsExpanded: Persistence.optional(Schema.Boolean),
}),
),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
return value
const general = value.general
if (!general || general.timelineDetail !== undefined) return value
return {
...value,
general: {
...value.general,
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
...general,
timelineDetail: {
shell: legacyTimelineActivity(general.shellToolPartsExpanded),
edit: legacyTimelineActivity(general.editToolPartsExpanded),
thinking: legacyTimelineActivity(
general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode,
),
},
},
}
}),
@@ -171,9 +245,7 @@ export const defaultSettings: Settings = {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -256,23 +328,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", 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)
timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail),
setTimelineDetail(value: TimelineDetail) {
setStore("general", "timelineDetail", structuredClone(unwrap(value)))
},
showCustomAgents,
setShowCustomAgents(value: boolean) {
+11 -7
View File
@@ -7,13 +7,17 @@
.settings-screen {
display: flex;
width: 100%;
height: 100%;
flex: 1;
width: calc(100% - 16px);
min-width: 0;
min-height: 0;
margin-inline: 8px;
margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px);
justify-content: center;
overflow: hidden;
background: var(--v2-background-bg-deep);
border-radius: 10px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
outline: none;
container: settings-screen / inline-size;
}
@@ -27,7 +31,7 @@
@media (max-width: 767px) {
.settings-screen {
--settings-mobile-inner-inset: 8px;
padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px);
margin-block-start: var(--settings-top-inset, var(--shell-top-inset, 8px));
}
}
@@ -51,7 +55,7 @@
.settings-screen .settings-tab-header {
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent);
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-screen .settings-tab-body {
@@ -349,7 +353,7 @@
gap: 16px;
padding: 8px var(--settings-mobile-inner-inset, 16px);
border-bottom: 0.5px solid var(--v2-border-border-muted);
background: var(--v2-background-bg-deep);
background: var(--v2-background-bg-base);
}
.settings-mobile-nav::after {
@@ -358,7 +362,7 @@
inset-inline: 0;
inset-block-start: 100%;
height: 1px;
background: var(--v2-background-bg-deep);
background: var(--v2-background-bg-base);
pointer-events: none;
}
+13 -24
View File
@@ -1,14 +1,4 @@
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Show,
onCleanup,
onMount,
startTransition,
} from "solid-js"
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -54,9 +44,7 @@ const sections = [
],
] as const
export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
export const SettingsScreen: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
@@ -66,7 +54,6 @@ export const SettingsScreen: Component<{
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
let root: HTMLDivElement | undefined
onMount(() => {
@@ -75,10 +62,8 @@ export const SettingsScreen: Component<{
})
onCleanup(() => command.keybinds(true))
createEffect(() => setTab(props.defaultValue ?? "general"))
const server = createMemo(() => {
const route = layout.route()
const route = surface.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
@@ -101,7 +86,7 @@ export const SettingsScreen: Component<{
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = layout.route()
const route = surface.route()
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
@@ -112,7 +97,7 @@ export const SettingsScreen: Component<{
const showProviders = () => {
dialog.close()
setTab("providers")
surface.open("providers")
}
return (
@@ -130,8 +115,8 @@ export const SettingsScreen: Component<{
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
class="settings"
>
<div class="settings-mobile-nav">
@@ -143,14 +128,18 @@ export const SettingsScreen: Component<{
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences",
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
<Menu.RadioGroup
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={sections}>
{(group, index) => (
<>
+36 -16
View File
@@ -1,32 +1,52 @@
import { useLocation } from "@solidjs/router"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
gate: false,
init: () => {
const location = useLocation()
const [store, setStore] = createStore({ open: false, tab: "general" })
const navigate = useNavigate()
const layout = useLayout()
const command = useCommand()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
}>()
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
let focus: HTMLElement | undefined
const close = () => {
if (!store.open) return
setStore("open", false)
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
}
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
createEffect(
on(
open,
(value) => {
if (value) return
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
},
{ defer: true },
),
)
return {
store,
active: open,
route: source,
tab: () => location.state?.settings?.tab ?? "general",
open(tab = "general") {
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
setStore({ open: true, tab })
const route = layout.route()
if (route.type !== "settings") {
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
}
navigate("/settings", {
replace: open(),
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
})
},
close() {
if (open()) command.trigger("common.goBack")
},
close,
}
},
})
@@ -0,0 +1,249 @@
[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;
}
}
@@ -0,0 +1,127 @@
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>
)
}
+3
View File
@@ -13,6 +13,7 @@ import { requireServerKey } from "./session"
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -20,6 +21,7 @@ const TargetSessionRouteContent = lazy(() =>
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -29,6 +31,7 @@ export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
+2 -13
View File
@@ -10,7 +10,6 @@ import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
export default function Layout(props: ParentProps) {
const platform = usePlatform()
@@ -86,24 +85,14 @@ export default function Layout(props: ParentProps) {
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)",
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
inert={settings.store.open}
aria-hidden={settings.store.open}
>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<Suspense>{props.children}</Suspense>
</div>
<Show when={settings.store.open}>
<Suspense>
<SettingsScreen defaultValue={settings.store.tab} />
</Suspense>
</Show>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
+5 -1
View File
@@ -3,9 +3,13 @@ import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
+2
View File
@@ -66,6 +66,7 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -104,6 +105,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
+36 -17
View File
@@ -8,56 +8,75 @@ function history(): TitlebarHistory {
describe("titlebar history", () => {
test("append and trim keeps max bounded", () => {
let state = history()
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
state = applyPath(state, { url: "/" }, 3)
state = applyPath(state, { url: "/a" }, 3)
state = applyPath(state, { url: "/b" }, 3)
state = applyPath(state, { url: "/c" }, 3)
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(state.stack.length).toBe(3)
expect(state.index).toBe(2)
})
test("back and forward indexes stay correct after trimming", () => {
let state = history()
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
state = applyPath(state, { url: "/" }, 3)
state = applyPath(state, { url: "/a" }, 3)
state = applyPath(state, { url: "/b" }, 3)
state = applyPath(state, { url: "/c" }, 3)
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(state.index).toBe(2)
const back = backPath(state)
expect(back?.to).toBe("/b")
expect(back?.to.url).toBe("/b")
expect(back?.state.index).toBe(1)
const afterBack = applyPath(back!.state, back!.to, 3)
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
expect(afterBack.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(afterBack.index).toBe(1)
const forward = forwardPath(afterBack)
expect(forward?.to).toBe("/c")
expect(forward?.to.url).toBe("/c")
expect(forward?.state.index).toBe(2)
const afterForward = applyPath(forward!.state, forward!.to, 3)
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
expect(afterForward.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(afterForward.index).toBe(2)
})
test("action-driven navigation does not push duplicate history entries", () => {
const state: TitlebarHistory = {
stack: ["/", "/a", "/b"],
stack: [{ url: "/" }, { url: "/a" }, { url: "/b" }],
index: 2,
action: undefined,
}
const back = backPath(state)
expect(back?.to).toBe("/a")
expect(back?.to.url).toBe("/a")
const next = applyPath(back!.state, back!.to, 10)
expect(next.stack).toEqual(["/", "/a", "/b"])
expect(next.stack.map((entry) => entry.url)).toEqual(["/", "/a", "/b"])
expect(next.index).toBe(1)
expect(next.action).toBeUndefined()
})
test("settings visits retain their own route state", () => {
const first = { url: "/settings", state: { settings: { type: "draft", draftID: "a" } } }
const second = { url: "/settings", state: { settings: { type: "draft", draftID: "b" } } }
const state = applyPath(applyPath(applyPath(history(), first), { url: "/b" }), second)
const back = backPath(state)!
const previous = backPath(applyPath(back.state, back.to))!
expect(previous.to).toEqual(first)
expect(forwardPath(applyPath(back.state, back.to))?.to).toEqual(second)
})
test("replacing settings state does not add a back navigation", () => {
const initial = applyPath(history(), { url: "/settings", state: { tab: "general" } })
const updated = applyPath(initial, { url: "/settings", state: { tab: "models" } })
expect(updated.stack).toHaveLength(2)
const back = backPath(updated)!
expect(back.to.url).toBe("/")
expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" })
})
})
+18 -8
View File
@@ -2,22 +2,32 @@ export const MAX_TITLEBAR_HISTORY = 100
export type TitlebarAction = "back" | "forward" | undefined
export type HistoryLocation = { url: string; state?: unknown }
export type TitlebarHistory = {
stack: string[]
stack: HistoryLocation[]
index: number
action: TitlebarAction
}
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
export function applyPath(
state: TitlebarHistory,
current: HistoryLocation,
max = MAX_TITLEBAR_HISTORY,
): TitlebarHistory {
if (!state.stack.length) {
const stack = current === "/" ? ["/"] : ["/", current]
const stack = current.url === "/" ? [current] : [{ url: "/" }, current]
return { stack, index: stack.length - 1, action: undefined }
}
const active = state.stack[state.index]
if (current === active) {
if (!state.action) return state
return { ...state, action: undefined }
if (current.url === active.url) {
if (!state.action && current.state === active.state) return state
return {
...state,
stack: state.stack.map((entry, index) => (index === state.index ? current : entry)),
action: undefined,
}
}
if (state.action) return { ...state, action: undefined }
@@ -25,13 +35,13 @@ export function applyPath(state: TitlebarHistory, current: string, max = MAX_TIT
return pushPath(state, current, max)
}
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
const stack = state.stack.slice(0, state.index + 1).concat(path)
const next = trimHistory(stack, stack.length - 1, max)
return { ...state, ...next, action: undefined }
}
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
export function trimHistory(stack: HistoryLocation[], index: number, max = MAX_TITLEBAR_HISTORY) {
if (stack.length <= max) return { stack, index }
const cut = stack.length - max
return {
+14 -1
View File
@@ -432,7 +432,20 @@ export function DraftTabItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Icon name="edit" />
<svg
class="text-v2-icon-icon-muted group-data-[active='true']:text-v2-icon-icon-base"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M9.00002 13.5H14M2.60419 10.9167V13.3958H5.08335L13.3959 5.08333L10.9167 2.60416L2.60419 10.9167Z"
stroke="currentColor"
/>
</svg>
</span>
<span
data-titlebar-tab-title
@@ -377,7 +377,7 @@ export function TitlebarTabStrip(props: {
index={visibleIndex()}
active={props.currentTab === tab}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("command.session.new")}
title={language.t("session.tab.session")}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
+48 -19
View File
@@ -1,5 +1,5 @@
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, unwrap } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -13,7 +13,7 @@ import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { WindowsAppMenu } from "./windows-menu"
import { applyPath, backPath, forwardPath } from "./history"
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
@@ -75,7 +75,7 @@ export function Titlebar(props: {
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
const [history, setHistory] = createStore({
stack: [] as string[],
stack: [] as HistoryLocation[],
index: 0,
action: undefined as "back" | "forward" | undefined,
})
@@ -83,7 +83,7 @@ export function Titlebar(props: {
const path = () => `${location.pathname}${location.search}${location.hash}`
createEffect(() => {
const current = path()
const current = { url: path(), state: location.state }
untrack(() => {
const next = applyPath(history, current)
@@ -113,14 +113,14 @@ export function Titlebar(props: {
const next = backPath(history)
if (!next) return
setHistory(next.state)
navigate(next.to)
navigate(next.to.url, { state: unwrap(next.to.state) })
}
const forward = () => {
const next = forwardPath(history)
if (!next) return
setHistory(next.state)
navigate(next.to)
navigate(next.to.url, { state: unwrap(next.to.state) })
}
command.register(() => [
@@ -297,6 +297,7 @@ export function Titlebar(props: {
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
case "settings":
case "home": {
const selection = layout.home.selection()
const conn =
@@ -432,7 +433,7 @@ export function Titlebar(props: {
"md:pl-4": !macTrafficLights(),
}}
>
<Show when={!mobile() && !props.verticalTabs}>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
@@ -624,10 +625,22 @@ export function Titlebar(props: {
>
<Show when={macVerticalTabs()}>
<div
class="relative w-full shrink-0"
class="relative mb-2 w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
></div>
>
<div
class="absolute -top-0.5 bottom-0.5 flex items-center"
style={{
// Native traffic lights stay on the physical left; subtract the sidebar padding.
left: macTrafficLights()
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
: "0px",
}}
>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
</Show>
{homeButton(true)}
<button
@@ -637,10 +650,10 @@ export function Titlebar(props: {
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
<Icon name="edit" />
{language.t("command.session.new")}
</button>
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
@@ -657,13 +670,14 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
<div class="absolute bottom-0 left-0 flex h-9 items-center">
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
>
<TitlebarRightMount />
<Show when={!macVerticalTabs() && !windows()}>
<ChannelIndicator debugTools={props.debugTools} />
</div>
<div class="absolute bottom-0 right-0 flex h-9 items-center">
<TitlebarRightMount />
</div>
</Show>
</div>
</Portal>
)}
@@ -740,12 +754,23 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
@@ -759,7 +784,11 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
return (
<Show when={label}>
{(value) => (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
<div
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
style={style()}
>
{value()}
</div>
)}
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import type { Path } from "@/runtime/server/types"
import type { LocationRef } from "@opencode-ai/client/promise"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -26,16 +26,19 @@ import {
displayPickerPath,
pickerParent,
pickerRoot,
listPickerDirectory,
pickerRelativePath,
pickerAbsolutePath,
} from "./domain"
import "./dialog.css"
import { Divider } from "@opencode-ai/ui/divider"
import { getFilename } from "@opencode-ai/util/path"
interface DirectoryPickerDialogProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
location?: LocationRef
mode?: "directory" | "file"
start?: string
}
@@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
let navigation = 0
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
() => (props.location ? undefined : true),
() => sdk.api.location.get().catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const home = createMemo(() => sync.data.path.home || "")
const location = createMemo(() => {
const current = props.location ?? fallbackPath()
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
})
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
props.location?.directory ||
sync.data.path.directory ||
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
@@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
const base = location()?.directory
if (!base) return { query: value, items: directories.slice(0, 5) }
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
location: location(),
query,
type: "file",
limit: 20,
})
@@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
]
return {
query: value,
@@ -132,15 +129,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined)
const current = location()
if (!current) return Promise.resolve(undefined)
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
@@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
createEffect(() => {
const path = start()
if (!path || root()) return
if (!path || !location() || root()) return
void navigate(path)
})
@@ -16,6 +16,7 @@ import {
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
listPickerDirectory,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
test("resolves directory autocomplete from the browser root without changing location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
find: (input: unknown) => {
calls.push(input)
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
await search("components")
expect(await search("components")).toEqual(["/repo/src/components"])
base = "/repo/src"
await search("components")
expect(await search("components")).toEqual(["/repo/src/components"])
expect(directories).toEqual(["/repo", "/repo/src"])
expect(calls).toEqual([
{ location, query: "components", type: "directory", limit: 50 },
{ location, query: "src/components", type: "directory", limit: 50 },
])
})
test("keeps indexed directory results for servers that support empty search", async () => {
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: async (input: { path?: string }) => {
calls.push(input)
return {
location,
data:
input.path === "/repo"
? [
{ path: "./", type: "directory" },
{ path: "../sibling/", type: "directory" },
]
: [{ path: "../sibling/src/", type: "directory" }],
}
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "current", absolute: "/repo/current", type: "directory" },
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
])
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
])
expect(calls).toEqual([
{ location, path: "/repo" },
{ location, path: "/repo/sibling" },
])
})
test("uses listings for typed searches outside the current location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.reject(new Error("outside searches must not change location")),
list: async (input: unknown) => {
calls.push(input)
return { location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
expect(await search("sib")).toEqual(["/repo/sibling"])
expect(calls).toEqual([{ location, path: "/repo" }])
})
test("keeps literal tilde directory names in server listing and search results", async () => {
const location = { directory: "/repo" }
const sdk = {
api: {
file: {
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "~", absolute: "/repo/~", type: "directory" },
])
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
expect(await search("nested")).toEqual(["/repo/~/nested"])
})
test("discards stale typed results without changing the request location", async () => {
const location = { directory: "/repo" }
const pending = Promise.withResolvers<{
location: typeof location
data: Array<{ path: string; type: "directory" }>
}>()
const calls: unknown[] = []
const sdk = {
api: {
file: {
find: async (input: { query: string }) => {
calls.push(input)
if (input.query === "old") return pending.promise
return { location, data: [{ path: "new/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
const stale = search("old")
expect(await search("new")).toEqual(["/repo/new"])
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
expect(await stale).toEqual([])
expect(calls).toEqual([
{ location, query: "old", type: "directory", limit: 50 },
{ location, query: "new", type: "directory", limit: 50 },
])
})
test("maps server-native drive and share paths without rebasing the location", async () => {
const calls: unknown[] = []
const sdk = {
api: {
file: {
list: async (input: { location: { directory: string }; path: string }) => {
calls.push(input)
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
])
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
])
expect(calls).toEqual([
{ location: drive, path: "c:/repo" },
{ location: share, path: "//server/share" },
])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const location = { directory: "/home/luke" }
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
@@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () =>
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
const results = await search("")
expect(results).toHaveLength(60)
@@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () =>
})
test("matches the default directory listing when typed search is unsupported", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
location,
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
@@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const location = { directory: "/" }
const directories: string[] = []
const sdk = {
api: {
@@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
@@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => {
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
+43 -19
View File
@@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
return pickerAbsolutePath(value, current)
}
export function pickerAbsolutePath(input: string, current: string) {
const value = normalizePickerDrive(input)
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
@@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) {
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
export async function listPickerDirectory(
sdk: ServerSDK,
location: { directory: string; workspace?: string },
directory: string,
) {
const result = await sdk.api.file.list({ location, path: directory })
return result.data.map((entry) => {
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
return { name: getFilename(absolute), type: entry.type, absolute }
})
}
export function createDirectorySearch(args: {
sdk: ServerSDK
location: () => { directory: string; workspace?: string } | undefined
base: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
@@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
}
const directories = async (directory: string) => {
const key = trimPickerPath(directory)
const location = args.location()
if (!location) return []
const key = JSON.stringify([location, trimPickerPath(directory)])
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
const request = listPickerDirectory(args.sdk, location, directory)
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
)
.then((nodes) => nodes.filter((node) => node.type === "directory"))
cache.set(key, request)
return request
}
@@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
if (!input) return [] as string[]
const location = args.location()
if (!input || !location) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
const relative = pickerRelativePath(location.directory, input.directory)
const results =
relative === undefined
? []
: await args.sdk.api.file
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
.then((result) =>
result.data
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
.filter((path) => treePathWithin(input.directory, path)),
)
.catch(() => [])
if (!active()) return []
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
return results.slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
@@ -16,6 +16,7 @@ describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy } from "solid-js"
import type { LocationRef } from "@opencode-ai/client/promise"
import { directoryPickerKind } from "./policy"
const DirectoryPickerDialog = lazy(() =>
@@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() =>
type DirectoryPickerInput = {
server: ServerConnection.Any
location?: LocationRef
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
+6 -1
View File
@@ -141,7 +141,12 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
execArgv: [
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
"--",
],
windows: {},
},
define: {
@@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
export default Runtime.handler(Commands, (input) =>
@@ -59,7 +59,7 @@ export default Runtime.handler(Commands, (input) =>
const service = server.service
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
},
+2 -2
View File
@@ -6,7 +6,7 @@ import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability"
import { Updater } from "./services/updater"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
@@ -113,7 +113,7 @@ Effect.gen(function* () {
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
client: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
+2 -2
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
@@ -86,7 +86,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const server = yield* start(
{
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
+1 -1
View File
@@ -1559,7 +1559,7 @@ export interface PermissionApi<E = never> {
export type FileListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: RelativePath | undefined
readonly path?: string | undefined
}
export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileListOperation<E = never> = (input?: FileListInput) => Effect.Effect<FileListOutput, E>
+2 -2
View File
@@ -1180,8 +1180,8 @@ export function createData(config: CreateDataInput) {
}))
break
case "reference.updated":
result.location.reference.invalidate()
void result.location.reference.sync()
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
break
case "integration.updated":
result.location.integration.invalidate(location)
@@ -119,6 +119,28 @@ test("multiple consumers share one source and receive live native and RPC events
await events.connections[0].closed
})
test("a slow consumer does not stall other subscribers", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const slow = shared.subscribe()[Symbol.asyncIterator]()
const fast = shared.subscribe()[Symbol.asyncIterator]()
const connected = [slow.next(), fast.next()]
events.connections[0].push({ type: "server.connected" })
await Promise.all(connected)
events.connections[0].push({ type: "permission.asked", value: 1 })
expect(await fast.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
events.connections[0].push({ type: "session.updated", value: 2 })
expect(await fast.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
expect(await slow.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
expect(await slow.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
await slow.return!()
await fast.return!()
await events.connections[0].closed
})
test("an idle consumer does not stall events for an active consumer", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
+60
View File
@@ -409,6 +409,66 @@ 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,6 +100,7 @@ const layer = Layer.effect(
editor.providers.set(providerID, current)
}
fn(current.provider)
current.provider.id = providerID
},
remove: (providerID) => {
editor.providers.delete(providerID)
+44 -120
View File
@@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
@@ -23,6 +23,8 @@ import { Location } from "./location.js"
import { AbsolutePath } from "./schema.js"
import { ConfigVariable } from "./config/variable.js"
import { ConfigNormalize } from "./config/normalize.js"
import { ConfigDiscovery } from "./config/discovery.js"
import { ConfigWatch } from "./config/watch.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -83,15 +85,12 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
@@ -177,90 +176,21 @@ export const layer = (options?: Options) =>
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return [
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
// Global roots and the walk are compared by canonical path: the same
// directory reached under two spellings (a symlinked checkout, macOS
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
// classify identically or it enters discovery twice.
const globalRoots = yield* Effect.forEach(
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
(item) => fs.resolve(item),
)
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
const discovered =
locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(
Effect.flatMap((items) =>
Effect.forEach(items, (item) =>
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
),
),
Effect.orDie,
)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below. With
// global enabled, the roots themselves and the global config files are
// already loaded below, so the walk must not add them a second time.
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
const explicit = sources.explicit
? yield* loadFile(sources.explicit).pipe(
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
@@ -281,15 +211,18 @@ export const layer = (options?: Options) =>
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(
sources.project.filter((root) => root.present),
(root) => loadDirectory(root.path),
).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...sources.claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
...sources.agents.map((path) => new AgentsDirectory({ type: "agents", path })),
...globalSupplementary,
...explicit,
...direct,
@@ -298,44 +231,35 @@ export const layer = (options?: Options) =>
]
})
const initial = yield* discover()
let configs = initial
const initial = yield* ConfigDiscovery.discover(options)
let configs = yield* load(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
const reloads = yield* PubSub.sliding<void>(1)
// Readiness rescans recover writes made before a watch attached.
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
const watched = yield* FiberMap.make<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
const plan = ConfigWatch.plan(sources)
for (const key of Array.from(watched, ([key]) => key)) {
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
}
for (const [key, target] of plan) {
yield* watcher
.subscribe(target, requestReload)
.pipe(
Effect.flatMap(
Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))),
),
FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }),
)
}
})
const reload = Effect.fn("Config.reload")(
function* () {
const next = yield* discover()
yield* reconcile(next)
const sources = yield* ConfigDiscovery.discover(options)
const next = yield* load(sources)
yield* reconcile(sources)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
@@ -343,12 +267,12 @@ export const layer = (options?: Options) =>
(effect) => reloadLock.withPermit(effect),
)
yield* Stream.fromPubSub(updates).pipe(
// Subscribe eagerly so synchronous watch readiness isn't dropped.
const pendingReloads = yield* PubSub.subscribe(reloads)
yield* Stream.fromSubscription(pendingReloads).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
reload().pipe(
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
@@ -389,7 +313,7 @@ export const layer = (options?: Options) =>
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
yield* reloadLock.withPermit(reconcile(initial))
return Service.of({
entries: Effect.fnUntraced(function* () {
+85
View File
@@ -0,0 +1,85 @@
export * as ConfigDiscovery from "./discovery.js"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { AbsolutePath } from "../schema.js"
import type { Options } from "../config.js"
export const names = ["opencode.json", "opencode.jsonc"]
/** Eligible sources in priority order, including paths that may appear later. */
export interface Sources {
readonly global?: AbsolutePath
readonly explicit?: AbsolutePath
readonly direct: readonly AbsolutePath[]
readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[]
readonly claude: readonly AbsolutePath[]
readonly agents: readonly AbsolutePath[]
}
export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) =>
fs.resolve(item),
)
const directories =
(yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false
? []
: yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie)
const discovered = yield* Effect.forEach(directories, (directory) =>
Effect.gen(function* () {
// Resolve the parent too: missing children must honor symlinked global roots.
const parent = yield* fs.resolve(directory)
const ecosystem = yield* Effect.filter([".claude", ".agents"], (name) => fs.exists(path.join(directory, name)))
return yield* Effect.forEach([...ecosystem, ".opencode", ...names.toReversed()], (name) =>
fs
.resolve(path.join(parent, name))
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
)
}),
).pipe(
Effect.map((items) => items.flat()),
Effect.orDie,
)
const globalEnabled = options?.global !== false
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
// Global sources must not re-enter through the project walk.
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
return {
global: globalEnabled ? globalDirectory : undefined,
explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined,
direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(),
project: yield* Effect.forEach(
visible.filter((item) => path.basename(item) === ".opencode").toReversed(),
(directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))),
),
claude: [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
],
agents: [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
],
} satisfies Sources
})
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
+37
View File
@@ -0,0 +1,37 @@
export * as ConfigWatch from "./watch.js"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { Watcher } from "../filesystem/watcher.js"
import type { ConfigDiscovery } from "./discovery.js"
export function plan(sources: ConfigDiscovery.Sources) {
const directories = [
...(sources.global ? [sources.global] : []),
...sources.project.filter((root) => root.present).map((root) => root.path),
]
const files = [
...sources.direct,
...sources.project.map((root) => root.path),
...(sources.explicit ? [sources.explicit] : []),
]
// Keep a parent watch for each root so deletion/recreation is observable.
const parents = Map.groupBy(
files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))),
(file) => path.dirname(file),
)
return new Map(
[
...directories.map((path) => ({
path,
type: "directory" as const,
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
})),
...Array.from(parents, ([parent, files]) => ({
path: parent,
type: "entries" as const,
names: [...new Set(files.map((file) => path.basename(file)))].toSorted(),
})),
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
)
}
+7 -6
View File
@@ -25,7 +25,7 @@ export const Content = Schema.Struct({
export type Content = typeof Content.Type
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@@ -93,17 +93,18 @@ const baseLayer = Layer.effect(
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
// Navigation can leave the cwd without activating another Location.
const directory = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(directory).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
return yield* fs.readDirectoryEntries(directory).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
const absolute = path.join(directory, item.name)
const relative = path.relative(location.directory, absolute) || "."
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
@@ -50,16 +50,14 @@ 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 = (ignore: readonly string[]) => {
const request = ++requested
return lock.withPermit(
const reconcile = () =>
lock.withPermit(
Effect.gen(function* () {
if (stopped || request !== requested) return
if (stopped) return
const resolved = yield* target
if (stopped || request !== requested) return
const ignore = policy.current()
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)
@@ -79,12 +77,10 @@ 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
}),
@@ -93,7 +89,7 @@ const layer = Layer.effect(
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* Plugin.awaitActivation
yield* reconcile(policy.current())
yield* reconcile()
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
+55 -57
View File
@@ -34,6 +34,7 @@ export type Update = ParcelWatcher.Event
export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "entries"; readonly names: readonly string[] }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export type Subscription = {
@@ -42,25 +43,26 @@ export type Subscription = {
readonly backend?: string
}
type Target = {
readonly target: string
readonly ignore: readonly string[]
} & (
| { readonly type: "entries"; readonly names: readonly string[] }
| { readonly type: "file" | "directory"; readonly names?: readonly string[] }
)
export interface NativeInterface {
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
readonly subscribe: (input: {
readonly type: WatchInput["type"]
readonly target: string
readonly ignore: readonly string[]
readonly publish: (update: Update) => void
}) => Effect.Effect<Subscription | undefined>
readonly subscribe: (
input: Target & { readonly publish: (update: Update) => void },
) => Effect.Effect<Subscription | undefined>
}
/**
* The OS-level watch implementation behind the Watcher service. The default
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
* tests provide implementations they can control.
*/
/** Uses fs.watch for immediate entries and Parcel for recursive directories. */
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
export interface Interface {
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
/** onReady runs after native acquisition and listener registration, when the stream is consumed. */
readonly subscribe: (input: WatchInput, onReady?: Effect.Effect<void>) => Effect.Effect<Stream.Stream<Update>>
}
export const Options = Schema.Struct({
@@ -89,16 +91,13 @@ export const layer = (options?: Options) =>
const native = yield* Native
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
const watchers = yield* RcMap.make({
lookup: (key: Key) =>
lookup: (key: Target) =>
Effect.gen(function* () {
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) => PubSub.shutdown(pubsub))
const subscription = yield* Effect.acquireRelease(
native.subscribe({
type: key.type,
target: key.target,
ignore: key.ignore,
...key,
publish: (update) => PubSub.publishUnsafe(pubsub, update),
}),
(subscription) =>
@@ -127,34 +126,31 @@ export const layer = (options?: Options) =>
}),
})
const subscribe = (input: WatchInput) => {
const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect<void> = Effect.void) {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
return Effect.gen(function* () {
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
return Stream.fromPubSub(pubsub)
}),
)
const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted()
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
}
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names })
const subscription = yield* PubSub.subscribe(pubsub)
if (yield* PubSub.isShutdown(pubsub)) return Stream.empty
yield* onReady
return Stream.fromSubscription(subscription)
}),
)
})
return Service.of({ subscribe })
}),
)
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and routes emitted updates the way the OS watches would: a
* file watch receives updates for its own path, a directory watch receives
* updates for paths inside it that no ignore entry covers.
*/
/** Real subscription lifecycle with in-memory, path-filtered event delivery. */
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
@@ -165,21 +161,22 @@ export const testLayer = Layer.effectContext(
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
: input.type === "entries"
? { path: input.target, type: "entries", names: input.names }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
// Ignore entries resolve against the target like the parcel wrapper's
// literal paths. Glob entries resolve to paths nothing lives under, so
// they are inert here rather than compiled the way parcel compiles them.
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
active.set(
input.publish,
input.type === "file"
? (target) => target === input.target
: (target) =>
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
)
active.set(input.publish, (target) => {
if (input.type === "file") return target === input.target
if (input.type === "entries")
return path.dirname(target) === input.target && input.names.includes(path.basename(target))
return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target))
})
return {
unsubscribe: () => {
active.delete(input.publish)
@@ -208,18 +205,19 @@ export const nativeLayer = Layer.succeed(
Native,
Native.of({
subscribe: (input) => {
if (input.type === "file") {
if (input.type === "file" || input.type === "entries") {
return Effect.sync(() => {
const directory = path.dirname(input.target)
const directory = input.type === "file" ? path.dirname(input.target) : input.target
const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names)
const subscription = watch(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== input.target) return
input.publish({ path: input.target, type: "update" } satisfies Update)
if (file && !names.has(file)) return
for (const name of file ? [file] : names) {
input.publish({ path: path.join(directory, name), type: "update" })
}
})
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })),
)
}
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })),
)
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
})
}
+2
View File
@@ -397,6 +397,7 @@ 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,
})
}),
},
@@ -407,6 +408,7 @@ 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,6 +32,7 @@ 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> {
@@ -98,6 +99,7 @@ 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* () {
+1 -4
View File
@@ -18,7 +18,6 @@ import { KV } from "./kv.js"
const Refresh = Schema.Struct({
attemptedAt: Schema.Number,
refreshedAt: Schema.optionalKey(Schema.Number),
})
const refreshInterval = Duration.toMillis(Duration.days(1))
@@ -161,7 +160,7 @@ const layer = Layer.effect(
if (status !== "cached") {
// Record attempts before network work so failures obey the same refresh interval.
yield* kv.set(key, { ...previous, attemptedAt: now })
yield* kv.set(key, { attemptedAt: now })
if (status === "cloned") {
yield* git.repo
@@ -205,8 +204,6 @@ 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))
+2
View File
@@ -74,6 +74,7 @@ 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
@@ -96,6 +97,7 @@ 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,6 +93,32 @@ 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(
+51 -8
View File
@@ -165,7 +165,16 @@ describe("Config", () => {
const entries = yield* config.entries()
expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global])
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"])
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
expect(
(yield* watcher.subscriptions())
.filter((subscription) => subscription.type === "directory")
.map((subscription) => subscription.path),
).toEqual([global])
expect(
(yield* watcher.subscriptions()).filter((subscription) =>
subscription.path.includes(`${path.sep}.opencode${path.sep}`),
),
).toEqual([])
})
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
@@ -230,6 +239,8 @@ describe("Config", () => {
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
@@ -243,17 +254,19 @@ describe("Config", () => {
),
)
it.live("reloads external config and publishes directory updates", () =>
it.live("reloads file substitutions when their source changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
const source = path.join(global, "shell.txt")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
await fs.writeFile(source, "first")
await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" }))
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
@@ -264,9 +277,8 @@ describe("Config", () => {
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* watcher.emit({ type: "update", path: file })
yield* Effect.promise(() => fs.writeFile(source, "second"))
yield* watcher.emit({ type: "update", path: source })
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
@@ -276,6 +288,35 @@ describe("Config", () => {
),
)
it.live("excludes missing files under symlinked global roots when global is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const link = path.join(tmp.path, "link")
const project = path.join(link, "plugins", "demo")
return Effect.promise(async () => {
await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true })
await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined)
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const watcher = yield* Watcher.Test
const subscriptions = yield* watcher.subscriptions()
expect(subscriptions.length).toBeGreaterThan(0)
expect(
subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)),
).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
),
),
),
)
}),
),
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
@@ -892,6 +933,7 @@ describe("Config", () => {
path: AbsolutePath.make(path.join(tmp.path, "global")),
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
},
{ type: "entries", path: tmp.path, names: [".opencode", "opencode.json", "opencode.jsonc"] },
])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
}),
@@ -1447,8 +1489,9 @@ describe("Config", () => {
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
expect(yield* watcher.subscriptions()).toContainEqual({
path: path.join(tmp.path, "opencode.jsonc"),
type: "file",
path: tmp.path,
type: "entries",
names: [".opencode", "opencode.json", "opencode.jsonc"],
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
+165 -1
View File
@@ -1,4 +1,6 @@
import path from "path"
import fs from "fs/promises"
import { writeFileSync } from "node:fs"
import { describe, expect } from "bun:test"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -20,12 +22,19 @@ import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { Credential } from "@opencode-ai/core/credential"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
@@ -34,6 +43,144 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.live("retains readiness signalled synchronously during initial config startup", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
if (input.type === "entries" && input.target === tmp.path) {
// No event is emitted: only synchronous readiness can trigger the reload.
writeFileSync(path.join(tmp.path, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } }))
}
return { unsubscribe: () => Promise.resolve() }
}),
})
return Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "docs")))
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
}),
),
)
it.live("loads the first config written while a new directory watch is starting", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const root = path.join(tmp.path, ".opencode")
const parent = yield* Deferred.make<(update: Watcher.Update) => void>()
const starting = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.gen(function* () {
if (input.type === "entries" && input.target === tmp.path) {
yield* Deferred.succeed(parent, input.publish)
}
if (input.type === "directory" && input.target === root) {
yield* Deferred.succeed(starting, undefined)
yield* Deferred.await(release)
}
return { unsubscribe: () => Promise.resolve() }
}),
})
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
const publish = yield* Deferred.await(parent)
yield* Effect.promise(() => fs.mkdir(root))
publish({ path: root, type: "create" })
yield* Deferred.await(starting).pipe(Effect.timeout("2 seconds"))
// No file event: the recursive native watch has not been acquired yet.
yield* Effect.promise(() =>
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })),
)
yield* Deferred.succeed(release, undefined)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(root, "docs")))
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
}),
),
),
)
for (const input of [
{ file: "opencode.json", empty: false },
{ file: "../opencode.jsonc", empty: false },
{ file: ".opencode/opencode.json", empty: false },
{ file: "../.opencode/opencode.jsonc", empty: true },
]) {
it.live(`loads references when ${input.file} is first created and keeps watching it`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const target = path.resolve(project, input.file)
yield* Effect.promise(() => fs.mkdir(project))
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
expect(yield* references.list()).toEqual([])
if (input.empty) {
yield* Effect.promise(() => fs.mkdir(path.dirname(target)))
const config = yield* Config.Service
yield* waitUntil(
config
.entries()
.pipe(
Effect.map((entries) =>
entries.some((entry) => entry.type === "directory" && entry.path === path.dirname(target)),
),
),
)
}
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
})
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))),
)
expect((yield* references.list())[0]?.path).toBe(
AbsolutePath.make(path.join(path.dirname(target), "docs")),
)
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
)
yield* Effect.promise(() =>
fs.rm(input.file.includes(".opencode/") ? path.dirname(target) : target, { recursive: true }),
)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.length === 0)))
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
})
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "docs")),
)
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
)
}).pipe(Effect.provide(liveConfig(project)))
}),
),
),
)
}
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -123,6 +270,23 @@ describe("config plugin reloads", () => {
)
})
function liveConfig(directory: string, native?: Watcher.NativeInterface) {
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [
Config.node.replace(Config.configured({ global: false })),
Location.node.replace(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
Global.node.replace(
Global.layerWith({ config: path.join(directory, "global"), home: path.join(directory, "home") }),
),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
...(native
? [Watcher.node.replace(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))]
: []),
])
}
function config(name: string) {
return new Document({
type: "document",
+42
View File
@@ -0,0 +1,42 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import type { ConfigDiscovery } from "@opencode-ai/core/config/discovery"
import { ConfigWatch } from "@opencode-ai/core/config/watch"
import { AbsolutePath } from "@opencode-ai/core/schema"
const project = path.resolve("watch-plan-project")
const root = AbsolutePath.make(path.join(project, ".opencode"))
const sources: ConfigDiscovery.Sources = {
direct: ["opencode.json", "opencode.jsonc"].map((name) => AbsolutePath.make(path.join(project, name))),
project: [{ path: root, present: false }],
claude: [AbsolutePath.make(path.join(project, ".claude"))],
agents: [AbsolutePath.make(path.join(project, ".agents"))],
}
describe("ConfigWatch.plan", () => {
test("groups missing candidates and keeps parent watches when roots appear", () => {
const missing = ConfigWatch.plan(sources)
expect(Array.from(missing.values())).toEqual([
{ path: project, type: "entries", names: [".opencode", "opencode.json", "opencode.jsonc"] },
])
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
expect(Array.from(present.values())).toEqual([
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
...missing.values(),
])
})
test("adds exact watches for explicit files only when not already covered", () => {
expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources))
const present = { ...sources, project: [{ path: root, present: true }] }
expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual(
ConfigWatch.plan(present),
)
const directory = path.resolve("watch-plan-external")
expect(
Array.from(
ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(),
),
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
})
})
+209 -17
View File
@@ -16,6 +16,7 @@ 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"
@@ -52,18 +53,121 @@ function countingNative() {
}
describe("Watcher lifecycle", () => {
it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () =>
Effect.gen(function* () {
const publish = yield* Deferred.make<(update: Watcher.Update) => void>()
const acquired = yield* Deferred.make<void>()
const counts = { ready: 0, closed: 0 }
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe(
{ path: "/shared", type: "entries", names: ["opencode.json"] },
Effect.gen(function* () {
counts.ready++
const notify = yield* Deferred.await(publish)
notify({ path: "/shared/opencode.json", type: "create" })
}),
)
.pipe(Effect.flatMap(Stream.runHead), Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(publish)
expect(counts.ready).toBe(0)
yield* Deferred.succeed(acquired, undefined)
expect(Option.getOrUndefined(yield* Fiber.join(consumer))).toEqual({
path: "/shared/opencode.json",
type: "create",
})
expect(counts).toEqual({ ready: 1, closed: 1 })
}).pipe(
withNative({
subscribe: (input) =>
Deferred.succeed(publish, input.publish).pipe(
Effect.andThen(Deferred.await(acquired)),
Effect.as({
unsubscribe: async () => {
counts.closed++
},
}),
),
}),
)
}),
)
it.effect("does not signal readiness for an unavailable native watch", () => {
const counts = { ready: 0 }
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const stream = yield* watcher.subscribe(
{ path: "/unavailable", type: "directory" },
Effect.sync(() => {
counts.ready++
}),
)
yield* Stream.runDrain(stream)
expect(counts.ready).toBe(0)
}).pipe(withNative({ subscribe: () => Effect.undefined }))
})
it.live("watches only named immediate entries, including missing directories", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const native = yield* Watcher.Native
const events: Watcher.Update[] = []
yield* Effect.acquireRelease(
native.subscribe({
type: "entries",
target: tmp.path,
names: ["opencode.json", ".opencode"],
ignore: [],
publish: (update) => events.push(update),
}),
(subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "nested"))
await fs.writeFile(path.join(tmp.path, "nested", "opencode.json"), "ignored")
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "ignored")
await fs.writeFile(path.join(tmp.path, "opencode.json"), "first")
})
yield* Effect.sync(() => events.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("1 second"),
)
expect(events.every((update) => update.path === path.join(tmp.path, "opencode.json"))).toBe(true)
yield* Effect.sleep("10 millis")
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
yield* Effect.sync(() => events.some((update) => update.path === path.join(tmp.path, ".opencode"))).pipe(
Effect.filterOrFail(Boolean),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("1 second"),
)
}).pipe(Effect.provide(Watcher.nativeLayer)),
),
),
)
it.effect("interrupting a consumer interrupts a pending acquisition", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const counts = { ready: 0 }
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.subscribe(
{ path: "/pending", type: "directory" },
Effect.sync(() => {
counts.ready++
}),
)
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
expect(counts.ready).toBe(0)
}).pipe(
withNative({
subscribe: () =>
@@ -76,16 +180,16 @@ describe("Watcher lifecycle", () => {
}),
)
it.effect("shares one subscription and releases exactly once after the final consumer", () => {
it.effect("shares equivalent entry sets and releases exactly once after the final consumer", () => {
const { native, counts } = countingNative()
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
const consume = (names: string[]) =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.subscribe({ path: "/shared", type: "entries", names })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
const first = yield* consume()
const second = yield* consume()
const first = yield* consume(["opencode.json", ".opencode", "opencode.json"])
const second = yield* consume([".opencode", "opencode.json"])
yield* Effect.yieldNow
expect(counts.subscribes).toBe(1)
@@ -125,6 +229,7 @@ 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,
@@ -137,6 +242,7 @@ function provide(
Location.node.replace(locationLayer),
plugins ?? PluginSupervisor.node.replace(Layer.empty),
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
...replacements,
],
)
return Effect.provide(built)
@@ -150,6 +256,7 @@ function withTmp<A, E, R>(
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: LayerNode.Replacement
replacements?: LayerNode.Replacements
},
) {
return Effect.acquireRelease(
@@ -172,7 +279,16 @@ 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)),
f(tmp.path, vcs).pipe(
provide(
tmp.path,
vcs,
options?.watcher,
options?.config ?? configLayer,
options?.plugins,
options?.replacements,
),
),
),
)
}
@@ -293,6 +409,64 @@ 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(
@@ -482,17 +656,35 @@ describeNative("LocationWatcher", () => {
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const started = yield* Deferred.make<string>()
const watcher = Layer.effect(
Watcher.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
const service = yield* Watcher.Service
return Watcher.Service.of({
subscribe: (input, onReady) =>
service.subscribe(
input,
Deferred.succeed(started, input.path).pipe(Effect.andThen(onReady ?? Effect.void)),
),
})
}),
{ vcs: "hg" },
),
).pipe(Layer.provide(AppNodeBuilder.build(Watcher.node)))
return yield* withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
// Use the actual acquisition barrier, not a probe write whose event
// callback can race the next write in Bun's filesystem watcher.
expect(yield* Deferred.await(started)).toBe(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg", watcher },
)
}),
)
})
+59 -4
View File
@@ -83,6 +83,34 @@ describe("FileSystem", () => {
),
)
it.live("lists parents and siblings with paths relative to the current location", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
expect(parent).toHaveLength(2)
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
expect.arrayContaining([
{ path: "." + path.sep, type: "directory" },
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
]),
)
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
])
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
expect(absolute).toEqual(sibling)
}).pipe(provide(current))
}),
),
)
it.live("canonicalizes local symlinked directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -105,10 +133,37 @@ describe("FileSystem", () => {
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
const current = path.join(directory, "current")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(current))
}),
),
)
it.live("allows listing through an external symlink without allowing file reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const current = path.join(directory, "current")
const outside = path.join(directory, "outside")
yield* Effect.promise(() => fs.mkdir(current))
yield* Effect.promise(() => fs.mkdir(outside))
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
])
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(current))
}),
),
)
})
+20
View File
@@ -19,6 +19,26 @@ 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>()
+30 -15
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, Schema } from "effect"
import { Clock, Duration, Effect, Layer } 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, refreshedAt: yesterday })
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
const results = yield* Effect.all(
@@ -61,7 +61,30 @@ describe("RepositoryCache", () => {
),
)
it.live("throttles failed refresh attempts without marking them successful", () =>
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", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
@@ -69,31 +92,23 @@ 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, refreshedAt: yesterday })
yield* kv.set(key, { attemptedAt: 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, refreshedAt: yesterday })
yield* kv.set(key, { attemptedAt: 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))),
),
)
@@ -157,13 +172,13 @@ describe("RepositoryCache", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const initial = yield* cache.ensure({ reference: fixture.reference })
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
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 })
const replaced = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
expect(replaced.status).toBe("cloned")
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
+19
View File
@@ -20,6 +20,25 @@ 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,6 +7,7 @@ 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,6 +5,7 @@ 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,6 +6,7 @@ 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,6 +5,7 @@ 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>>
}
interface ToolEditor {
export interface ToolEditor {
list(): readonly (Info & { readonly id: string })[]
get(id: string): (Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
+6 -3
View File
@@ -1,13 +1,15 @@
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/schema/location"
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { PositiveInt } from "@opencode-ai/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const ListQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional).annotate({
description: "An absolute path or a path relative to the requested location. Defaults to the location directory.",
}),
})
const FindQuery = Schema.Struct({
@@ -42,7 +44,8 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
OpenApi.annotations({
identifier: "v2.fs.list",
summary: "List directory",
description: "List direct children of one directory relative to the requested location.",
description:
"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
}),
),
)
@@ -0,0 +1,20 @@
const fs = require("node:fs")
const readline = require("node:readline")
fs.appendFileSync(process.argv[2], `${process.pid}\n`)
readline
.createInterface({ input: process.stdin })
.on("line", (line) => {
const request = JSON.parse(line)
if (request.id === undefined) return
const result =
request.method === "initialize"
? {
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "filesystem-test", version: "1" },
}
: { tools: [] }
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n")
})
.on("close", () => process.exit(0))
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live(
"browsing parents and siblings reuses the current Location and its MCP process",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const current = path.join(tmp.path, "root", "current")
const starts = path.join(tmp.path, "starts")
yield* Effect.promise(async () => {
await fs.mkdir(current, { recursive: true })
await fs.mkdir(path.join(tmp.path, "root", ".git"))
await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true })
await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside")
await fs.writeFile(starts, "")
await fs.writeFile(
path.join(tmp.path, "root", "opencode.json"),
JSON.stringify({
mcp: {
servers: {
filesystem: {
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts],
},
},
},
}),
)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const list = (directory: string) =>
Effect.promise(async () => {
const url = new URL("/api/fs/list", server.base)
url.searchParams.set("location[directory]", current)
url.searchParams.set("path", directory)
const response = await fetch(url, { headers: server.headers })
expect(response.status).toBe(200)
const result = await response.json()
expect(result.location.directory).toBe(current)
return result.data
})
const loaded = Effect.promise(async () => {
const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })
expect(response.status).toBe(200)
return response.json()
})
const count = Effect.promise(
async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length,
)
yield* list(".")
expect(yield* loaded).toEqual([{ directory: current }])
expect(
yield* count.pipe(
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
Effect.timeout("5 seconds"),
),
).toBe(1)
yield* list("..")
const sibling = yield* list("../sibling")
expect(sibling).toEqual([
{ path: path.join("..", "sibling", "nested") + path.sep, type: "directory" },
{ path: path.join("..", "sibling", "file.txt"), type: "file" },
])
expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling)
yield* list("../sibling/nested")
yield* list("../sibling")
expect(yield* loaded).toEqual([{ directory: current }])
expect(yield* count).toBe(1)
}),
15_000,
)
+1
View File
@@ -9,6 +9,7 @@
"./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",

Some files were not shown because too many files have changed in this diff Show More