mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 15:36:22 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
887f319769 | ||
|
|
24f6cb51c8 | ||
|
|
d2bec894bf | ||
|
|
d7009156a6 | ||
|
|
4cfca7ab68 | ||
|
|
ae7d9f029d | ||
|
|
9acdb0be18 | ||
|
|
b42555cf01 | ||
|
|
cfe9f13963 | ||
|
|
48f246695e | ||
|
|
4f6060ad94 | ||
|
|
bf6ec61a74 | ||
|
|
cf298f3409 | ||
|
|
0089ac9b02 | ||
|
|
4680a4aa6f | ||
|
|
88e4ab5735 | ||
|
|
efefd90443 | ||
|
|
2b87169cc1 | ||
|
|
962c26bdf2 | ||
|
|
22de01e84f | ||
|
|
8565cb52a1 | ||
|
|
050398f51f | ||
|
|
5f1d74fd3f | ||
|
|
d9c85d8d95 | ||
|
|
1c77b1c920 | ||
|
|
27f838f249 | ||
|
|
c992716523 | ||
|
|
e402600d92 | ||
|
|
1e4e9c5d85 | ||
|
|
f34b74b1dc | ||
|
|
b257d476c9 | ||
|
|
5e38101fba | ||
|
|
4beaffbda9 | ||
|
|
21bbcc33a1 | ||
|
|
0b1dbaf621 | ||
|
|
0da51e9274 | ||
|
|
dea1f9cb7b | ||
|
|
969055918f | ||
|
|
f269025416 | ||
|
|
4643cacb4a | ||
|
|
68a40e2d21 | ||
|
|
57c02cd04b | ||
|
|
46515df4a3 | ||
|
|
51250e4346 | ||
|
|
dbd47702b2 | ||
|
|
a085bf62a4 | ||
|
|
b605f355ca | ||
|
|
85e2b0a23a | ||
|
|
3dc187bf3f | ||
|
|
ed3259a9b7 | ||
|
|
db09cc842e |
@@ -47,7 +47,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'v2' || github.ref_name == 'beta'
|
||||
if: github.ref_name == 'v2'
|
||||
working-directory: packages/updates
|
||||
run: bun run deploy
|
||||
env:
|
||||
|
||||
@@ -575,6 +575,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -585,6 +586,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -971,7 +973,6 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1045,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",
|
||||
@@ -1123,7 +1125,6 @@
|
||||
"opentui-spinner": "0.0.7",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.1",
|
||||
"resolve.exports": "2.0.3",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "4.4.3",
|
||||
"solid-js": "1.9.15",
|
||||
@@ -5171,8 +5172,6 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
|
||||
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
|
||||
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
|
||||
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
|
||||
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
|
||||
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
|
||||
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
|
||||
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -27,7 +27,6 @@
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
@@ -93,7 +92,6 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"resolve.exports": "2.0.3",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "4.4.3",
|
||||
"solid-list": "0.3.0",
|
||||
@@ -179,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -9,30 +9,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
const unresolvedSessionID = "ses_tab_unresolved"
|
||||
|
||||
test("new session tab hugs its content", async ({ page }) => {
|
||||
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "draft", server, directory, draftID: "draft_tab_width" },
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, directory: sessionA.directory },
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
|
||||
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
|
||||
await expect(draftTab).toContainText("New session")
|
||||
const width = (await draftTab.boundingBox())?.width ?? 0
|
||||
expect(width).toBeGreaterThan(100)
|
||||
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
|
||||
const tabs = page.locator("[data-titlebar-tab-slot]")
|
||||
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",
|
||||
})
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
tabs.evaluateAll((tabs) => {
|
||||
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
|
||||
return Math.max(...widths) - Math.min(...widths)
|
||||
}),
|
||||
)
|
||||
.toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
@@ -182,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()
|
||||
@@ -280,9 +292,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
|
||||
"tab-project",
|
||||
])
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
@@ -85,6 +85,15 @@ describe("normalizeProviderList", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeProjectInfo", () => {
|
||||
test("keeps the project VCS backend", () => {
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
|
||||
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryKey", () => {
|
||||
test("normalizes slashes", () => {
|
||||
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
|
||||
|
||||
@@ -135,6 +135,5 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
|
||||
...project,
|
||||
worktree,
|
||||
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
|
||||
const options = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const project = input.session.project()
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs) list.push("git")
|
||||
if (
|
||||
project?.vcs === "git" &&
|
||||
project?.vcs &&
|
||||
vcs()?.branch.current &&
|
||||
vcs()?.branch.default &&
|
||||
vcs()?.branch.current !== vcs()?.branch.default
|
||||
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
|
||||
const value = vcsMode()
|
||||
return {
|
||||
queryKey: [...vcsKey(), value] as const,
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: value
|
||||
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
|
||||
})
|
||||
const detailsQuery = createQuery(() => ({
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
|
||||
queryFn: () =>
|
||||
server.api.vcs
|
||||
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
|
||||
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
|
||||
tab: () => state.mobileTab,
|
||||
},
|
||||
mode,
|
||||
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
|
||||
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
|
||||
open,
|
||||
openFile,
|
||||
options,
|
||||
|
||||
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
baseBranch={
|
||||
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
|
||||
}
|
||||
diffs={project().vcs === "git" ? review.details.diffs() : []}
|
||||
diffs={project().vcs ? review.details.diffs() : []}
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
moveEligible={composer.workspaceMoveEligible()}
|
||||
moveDismissed={store.mobileMoveDismissed}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={() => (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -395,11 +395,8 @@ export function DraftTabItem(props: {
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
|
||||
classList={{
|
||||
invisible: props.hidden,
|
||||
"w-full [container-type:inline-size]": props.orientation === "vertical",
|
||||
}}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
@@ -432,16 +429,27 @@ export function DraftTabItem(props: {
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 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]"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
|
||||
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
|
||||
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
|
||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
||||
data-orientation={props.orientation}
|
||||
class="relative flex"
|
||||
classList={{
|
||||
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
|
||||
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
|
||||
"w-full shrink-0": props.orientation === "vertical",
|
||||
}}
|
||||
>
|
||||
@@ -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)
|
||||
|
||||
@@ -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(["/"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,7 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
bytecode: true,
|
||||
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
|
||||
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
|
||||
bytecode: false,
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
@@ -139,13 +141,19 @@ 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: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CLI_NAME: `'${binary}'`,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_ARTIFACT: `'cli'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
||||
|
||||
@@ -23,7 +23,13 @@ async function publish(dir: string, name: string, version: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
|
||||
async function publishDistribution(input: {
|
||||
root: string
|
||||
name: string
|
||||
binary: string
|
||||
packagePrefix: string
|
||||
artifact: string
|
||||
}) {
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
|
||||
const item = await Bun.file(`${input.root}/${filepath}`).json()
|
||||
@@ -72,6 +78,13 @@ async function publishDistribution(input: { root: string; name: string; binary:
|
||||
),
|
||||
)
|
||||
await publish(`${input.root}/${input.name}`, input.name, version)
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: input.artifact,
|
||||
distribution: "npm",
|
||||
version,
|
||||
metadata: { package: input.name },
|
||||
})
|
||||
}
|
||||
|
||||
await publishDistribution({
|
||||
@@ -79,6 +92,7 @@ await publishDistribution({
|
||||
name: pkg.name,
|
||||
binary: "opencode2",
|
||||
packagePrefix: "@opencode-ai/cli-",
|
||||
artifact: "cli",
|
||||
})
|
||||
if (existsSync("./dist/node")) {
|
||||
await publishDistribution({
|
||||
@@ -86,12 +100,6 @@ if (existsSync("./dist/node")) {
|
||||
name: "opencode-node",
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
artifact: "cli-node",
|
||||
})
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "npm",
|
||||
version: Script.version,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -93,7 +93,11 @@ try {
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
// Windows can retain directory handles briefly after the service processes exit.
|
||||
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
|
||||
console.error("Failed to remove service smoke-test directory", cause)
|
||||
failure ??= cause
|
||||
})
|
||||
if (failure)
|
||||
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
|
||||
cause: failure,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -89,12 +89,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
}
|
||||
: undefined,
|
||||
packages: {
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
@@ -16,9 +17,9 @@ export default Runtime.handler(
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
|
||||
const installed = yield* npm.add(input.package)
|
||||
const entrypoints = Host.resolve(installed)
|
||||
const target = configurationTarget(entrypoints.server, entrypoints.tui)
|
||||
if (!target)
|
||||
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
|
||||
tuiTargets,
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
const installed = yield* npm.resolve(target)
|
||||
const outdated = yield* npm.check(target).pipe(Effect.exit)
|
||||
return {
|
||||
runtime: "TUI" as const,
|
||||
|
||||
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -22,27 +23,27 @@ export default Runtime.handler(
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
|
||||
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
|
||||
return []
|
||||
const local = localSource(target, path.dirname(config.path))
|
||||
if (local) return [{ target: fileURLToPath(local), version: "local" }]
|
||||
if (local) {
|
||||
const directory = fileURLToPath(local)
|
||||
const entrypoints = Host.resolve({ directory })
|
||||
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
|
||||
}
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
if (!installed.entrypoint) return []
|
||||
const installed = yield* npm.resolve(target)
|
||||
if (!Host.resolve(installed).tui) return []
|
||||
return [{ target, version: installed.version }]
|
||||
}),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
|
||||
input.builtin,
|
||||
)
|
||||
const output = format(response.data, configured.flat(), input.builtin)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
return
|
||||
@@ -81,9 +82,7 @@ export function format(
|
||||
? plugin.source.target === item.target
|
||||
: plugin.source.type === "local" &&
|
||||
(plugin.source.path === item.target ||
|
||||
(plugin.features.tui &&
|
||||
path.dirname(plugin.source.path) ===
|
||||
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
|
||||
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
|
||||
),
|
||||
)
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default Runtime.handler(
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
: npm.update(item.target).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((result) => ({ item, result })),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -2,21 +2,16 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
const packageName =
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
@@ -180,6 +175,15 @@ const make = Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
const directory = path.dirname(path.dirname(executable))
|
||||
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
|
||||
.readFileString(path.join(directory, "package.json"))
|
||||
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
|
||||
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
|
||||
return manifest.name
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
@@ -216,10 +220,11 @@ const make = Effect.gen(function* () {
|
||||
process.platform === "win32" ? "opencode2.exe" : "opencode2",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
@@ -228,35 +233,49 @@ const make = Effect.gen(function* () {
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}),
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName = (yield* release()).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
"install",
|
||||
"--global",
|
||||
...(installedPackage && packageName !== installedPackage ? ["--force"] : []),
|
||||
target,
|
||||
],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
declare const OPENCODE_VERSION: string
|
||||
declare const OPENCODE_CHANNEL: string
|
||||
declare const OPENCODE_ARTIFACT: string
|
||||
|
||||
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
const artifact = typeof OPENCODE_ARTIFACT === "string" ? OPENCODE_ARTIFACT : "cli"
|
||||
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL, artifact as OPENCODE_ARTIFACT }
|
||||
export const OPENCODE_LOCAL = channel === "local"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { expect, test } from "bun:test"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { Effect, FileSystem, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -17,11 +17,29 @@ function fixture(
|
||||
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
|
||||
error?: AppProcess.AppProcessError
|
||||
} = () => ({}),
|
||||
name = "@opencode-ai/cli",
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
|
||||
const executable = path.join(root, "package", "bin", "opencode")
|
||||
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
|
||||
yield* fs.writeFileString(
|
||||
path.join(root, "package", "package.json"),
|
||||
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
|
||||
)
|
||||
// The updater uses global fetch; scope this replacement to each install test.
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
),
|
||||
),
|
||||
(request) => Effect.sync(() => request.mockRestore()),
|
||||
)
|
||||
const global = Global.make({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
@@ -37,6 +55,10 @@ function fixture(
|
||||
const updater = yield* Updater.Service.pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
}),
|
||||
Effect.provideService(
|
||||
AppProcess.Service,
|
||||
AppProcess.Service.of({
|
||||
@@ -214,9 +236,12 @@ test("Node distribution honors the compile-time CLI name", async () => {
|
||||
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
|
||||
it.live("Node distribution resolves the published npm package", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}))
|
||||
const test = yield* fixture(
|
||||
(command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}),
|
||||
"opencode-node",
|
||||
)
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
|
||||
@@ -268,6 +268,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
OPENCODE_VERSION: JSON.stringify(input.version),
|
||||
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_ARTIFACT: JSON.stringify("cli-node"),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -985,15 +985,6 @@ export type ReferenceUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type PluginAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "plugin.added"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type PluginUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2299,7 +2290,6 @@ export type V2Event =
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectUpdated
|
||||
| WorktreeUpdated
|
||||
|
||||
+220
-429
@@ -83,10 +83,8 @@ type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: {
|
||||
server?: McpServer[]
|
||||
resource?: McpResource[]
|
||||
}
|
||||
mcpServer?: McpServer[]
|
||||
mcpResource?: McpResource[]
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderInfo[]
|
||||
reference?: ReferenceInfo[]
|
||||
@@ -109,7 +107,6 @@ type Store = {
|
||||
messageCursor: Record<string, string | undefined>
|
||||
messageLoading: Record<string, boolean>
|
||||
pending: Record<string, SessionInboxInfo[]>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||
form: Record<string, FormWithLocation[]>
|
||||
@@ -125,8 +122,8 @@ export function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
function locationQuery(ref: LocationRef) {
|
||||
return { directory: ref.directory, workspace: ref.workspaceID }
|
||||
}
|
||||
|
||||
function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
@@ -200,7 +197,6 @@ export function createData(config: CreateDataInput) {
|
||||
messageCursor: {},
|
||||
messageLoading: {},
|
||||
pending: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
form: {},
|
||||
},
|
||||
@@ -233,13 +229,6 @@ export function createData(config: CreateDataInput) {
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
|
||||
)
|
||||
if (store.session.input[sessionID]?.includes(inboxID))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
|
||||
)
|
||||
}
|
||||
|
||||
function removePermission(sessionID: string, requestID: string) {
|
||||
@@ -337,8 +326,8 @@ export function createData(config: CreateDataInput) {
|
||||
return request
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// Upsert an admitted inbox item into pending and (for user and synthetic
|
||||
// items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic admission; the upsert is what reconciles
|
||||
// the durable echo with an optimistic placeholder — the durable payload and
|
||||
// times replace the client's guess.
|
||||
@@ -353,8 +342,6 @@ export function createData(config: CreateDataInput) {
|
||||
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
|
||||
)
|
||||
if (item.type === "compaction") return
|
||||
const input = store.session.input[item.sessionID] ?? []
|
||||
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
|
||||
materializeInboxMessage(item)
|
||||
})
|
||||
}
|
||||
@@ -403,15 +390,44 @@ export function createData(config: CreateDataInput) {
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
insert(sessionID: string, item: SessionMessageInfo) {
|
||||
message.update(sessionID, (draft, index) => message.append(draft, index, item))
|
||||
},
|
||||
// Streaming events target one assistant message and, within it, the latest part of a kind.
|
||||
// A missing target means the row was never loaded or was evicted; the event is dropped.
|
||||
editAssistant(sessionID: string, messageID: string, fn: (assistant: SessionMessageAssistant) => void) {
|
||||
message.update(sessionID, (draft, index) => {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : draft[position]
|
||||
if (item?.type === "assistant") fn(item)
|
||||
})
|
||||
},
|
||||
editTool(sessionID: string, messageID: string, toolID: string, fn: (tool: SessionMessageAssistantTool) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const tool = assistant.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && item.id === toolID,
|
||||
)
|
||||
if (tool) fn(tool)
|
||||
})
|
||||
},
|
||||
editText(sessionID: string, messageID: string, fn: (text: SessionMessageAssistantText) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const text = assistant.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
if (text) fn(text)
|
||||
})
|
||||
},
|
||||
editReasoning(sessionID: string, messageID: string, fn: (reasoning: SessionMessageAssistantReasoning) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const reasoning = assistant.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
if (reasoning) fn(reasoning)
|
||||
})
|
||||
},
|
||||
activeAssistant(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : messages[position]
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
shell(messages: SessionMessageInfo[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
@@ -420,19 +436,6 @@ export function createData(config: CreateDataInput) {
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||
for (let position = start; position < messages.length; position++) {
|
||||
const item = messages[position]
|
||||
@@ -510,12 +513,8 @@ export function createData(config: CreateDataInput) {
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
if (messages.length) draft.message[sessionID] = messages
|
||||
if (pending.length) {
|
||||
draft.pending[sessionID] = pending
|
||||
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
|
||||
}
|
||||
if (pending.length) draft.pending[sessionID] = pending
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -539,7 +538,6 @@ export function createData(config: CreateDataInput) {
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.form[sessionID]
|
||||
for (const [rootID, family] of Object.entries(draft.family)) {
|
||||
@@ -611,14 +609,12 @@ export function createData(config: CreateDataInput) {
|
||||
const previous = store.session.info[event.data.sessionID]?.agent
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -626,13 +622,11 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
if (!store.session.message[event.data.sessionID]) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
void api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
@@ -667,16 +661,14 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "info", event.data.sessionID, "location", event.data.location)
|
||||
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
|
||||
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -706,7 +698,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
||||
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID)
|
||||
removePending(event.data.sessionID, event.data.inboxID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inboxID)
|
||||
@@ -750,42 +742,36 @@ export function createData(config: CreateDataInput) {
|
||||
// and produce no transcript message.
|
||||
const updateText = event.data.text
|
||||
if (updateText === undefined) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.synthetic":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.shell.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.shell.ended":
|
||||
@@ -800,9 +786,8 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.message.content.updated": {
|
||||
if (store.session.message[event.data.sessionID])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const assistant = message.assistant(draft, index, event.data.messageID)
|
||||
if (assistant) assistant.content = [...event.data.content]
|
||||
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
|
||||
assistant.content = [...event.data.content]
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
@@ -844,65 +829,54 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.step.streamed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (currentAssistant) currentAssistant.time.streamed = event.created
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.streamed = event.created
|
||||
})
|
||||
return
|
||||
case "session.step.ended": {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.step.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish ?? "error"
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish ?? "error"
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.error = event.data.error
|
||||
assistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
}
|
||||
})
|
||||
return
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({ type: "text", text: "" })
|
||||
})
|
||||
return
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
||||
text.text += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
||||
text.text = event.data.text
|
||||
})
|
||||
return
|
||||
case "session.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
@@ -912,86 +886,60 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "streaming") tool.state.input += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "streaming") tool.state.input = event.data.text
|
||||
})
|
||||
return
|
||||
case "session.tool.called":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, metadata: {} }
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
tool.time.ran = event.created
|
||||
tool.executed = event.data.executed
|
||||
tool.providerState = event.data.state
|
||||
tool.state = { status: "running", input: event.data.input, metadata: {} }
|
||||
})
|
||||
return
|
||||
case "session.tool.progress":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.metadata = event.data.metadata
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "running") tool.state.metadata = event.data.metadata
|
||||
})
|
||||
return
|
||||
case "session.tool.success":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status !== "running") return
|
||||
tool.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
input: tool.state.input,
|
||||
metadata: event.data.metadata,
|
||||
content: [...event.data.content],
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
tool.executed = event.data.executed || tool.executed === true
|
||||
tool.providerResultState = event.data.resultState
|
||||
tool.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.tool.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return
|
||||
tool.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
tool.executed = event.data.executed || tool.executed === true
|
||||
tool.providerResultState = event.data.resultState
|
||||
tool.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
@@ -1000,30 +948,20 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
||||
reasoning.text += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
||||
reasoning.text = event.data.text
|
||||
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) reasoning.state = event.data.state
|
||||
})
|
||||
return
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: event.data.at,
|
||||
error: event.data.error,
|
||||
}
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error }
|
||||
})
|
||||
return
|
||||
case "session.execution.started":
|
||||
@@ -1031,16 +969,14 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.compaction.started":
|
||||
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
@@ -1075,11 +1011,12 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
@@ -1243,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)
|
||||
@@ -1273,6 +1210,41 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
|
||||
// effective location, and publishes under the server's canonical location; `alias`
|
||||
// also publishes under the requested key when the two differ.
|
||||
function locationResource<Field extends keyof LocationData>(
|
||||
field: Field,
|
||||
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
|
||||
options?: { alias?: boolean },
|
||||
) {
|
||||
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
|
||||
return {
|
||||
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
|
||||
sync: (ref?: LocationRef) => {
|
||||
const location = ref ?? defaultLocation()
|
||||
const id = locationKey(location)
|
||||
return sync.run(`location.${field}:${id}`, async () => {
|
||||
const response = await load(locationQuery(location))
|
||||
const key = locationKey(response.location)
|
||||
publish(key, response.data)
|
||||
if (options?.alias && key !== id) publish(id, response.data)
|
||||
})
|
||||
},
|
||||
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
|
||||
}
|
||||
}
|
||||
|
||||
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
|
||||
const shells = locationResource("shell", async (location) => {
|
||||
const response = await api().shell.list({ location })
|
||||
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
|
||||
return {
|
||||
location: response.location,
|
||||
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
on: config.event.on,
|
||||
listen: config.event.listen,
|
||||
@@ -1321,12 +1293,17 @@ export function createData(config: CreateDataInput) {
|
||||
status(sessionID: string) {
|
||||
return store.session.active[sessionID] ?? "idle"
|
||||
},
|
||||
// Inputs are the pending user and synthetic items; compactions are control items.
|
||||
input: {
|
||||
list(sessionID: string) {
|
||||
return store.session.input[sessionID] ?? []
|
||||
return (store.session.pending[sessionID] ?? []).flatMap((item) =>
|
||||
item.type === "compaction" ? [] : [item.id],
|
||||
)
|
||||
},
|
||||
has(sessionID: string, inboxID: string) {
|
||||
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
||||
return (
|
||||
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
|
||||
)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
@@ -1350,12 +1327,6 @@ export function createData(config: CreateDataInput) {
|
||||
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
|
||||
batch(() => {
|
||||
setStore("session", "pending", sessionID, reconcile(merged))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
merged.forEach(materializeInboxMessage)
|
||||
})
|
||||
})
|
||||
@@ -1740,7 +1711,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
shell: {
|
||||
list(location?: LocationRef) {
|
||||
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
|
||||
return Object.values(shells.list(location) ?? {})
|
||||
},
|
||||
listBySession(sessionID: string) {
|
||||
return Object.values(store.location)
|
||||
@@ -1752,31 +1723,8 @@ export function createData(config: CreateDataInput) {
|
||||
.map((data) => data.shell?.[id])
|
||||
.find((shell) => shell !== undefined)
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.shell:${id}`, async () => {
|
||||
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
shell: Object.fromEntries(
|
||||
response.data.map((info) => [
|
||||
info.id,
|
||||
{
|
||||
...info,
|
||||
location: {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
sync: shells.sync,
|
||||
invalidate: shells.invalidate,
|
||||
},
|
||||
location: {
|
||||
info(ref?: LocationRef) {
|
||||
@@ -1831,162 +1779,20 @@ export function createData(config: CreateDataInput) {
|
||||
result.shell.invalidate(location)
|
||||
result.session.form.invalidate("global", location)
|
||||
},
|
||||
vcs: {
|
||||
info(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.vcs
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const location = ref ?? defaultLocation()
|
||||
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
|
||||
const response = await api().vcs.get({ location: locationQuery(location) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], vcs: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.agent:${id}`, async () => {
|
||||
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], agent: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.command:${id}`, async () => {
|
||||
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], command: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.integration:${id}`, async () => {
|
||||
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], integration: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
||||
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
||||
command: locationResource("command", (location) => api().command.list({ location })),
|
||||
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
||||
mcp: {
|
||||
server: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.server:${id}`, async () => {
|
||||
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, server: response.data },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.resource:${id}`, async () => {
|
||||
const response = await api().mcp.resource.catalog({
|
||||
location: locationQuery(ref ?? defaultLocation()),
|
||||
})
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.model:${id}`, async () => {
|
||||
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], model: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.provider:${id}`, async () => {
|
||||
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], provider: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
reference: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.reference:${id}`, async () => {
|
||||
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], reference: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
||||
resource: locationResource("mcpResource", async (location) => {
|
||||
const response = await api().mcp.resource.catalog({ location })
|
||||
return { location: response.location, data: response.data.resources }
|
||||
}),
|
||||
},
|
||||
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
|
||||
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
|
||||
reference: locationResource("reference", (location) => api().reference.list({ location })),
|
||||
websearch: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.websearch
|
||||
@@ -2001,22 +1807,7 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.skill:${id}`, async () => {
|
||||
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], skill: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ 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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -20378,44 +20378,6 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.added": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["plugin.added"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23123,9 +23085,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/permission.v2.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.added"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.updated"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
|
||||
"build": "bun run script/build.ts",
|
||||
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
|
||||
"test": "bun run script/test.ts",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Measures the heap retained by Location service graphs and by the models.dev
|
||||
// catalog plugin. Everything runs against a temporary global directory with a
|
||||
// temporary home, an in-memory database, no filesystem watchers, and no network,
|
||||
// so it never touches a live server, database, or user configuration.
|
||||
//
|
||||
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
|
||||
//
|
||||
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
|
||||
// highest heapUsed sampled without forcing GC and are reported separately.
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { heapStats } from "bun:jsc"
|
||||
import { Effect, Layer, Logger, Scope } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Catalog } from "../src/catalog"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Integration } from "../src/integration"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Plugin } from "../src/plugin"
|
||||
import { ModelsDevPlugin } from "../src/plugin/models-dev"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Watcher } from "../src/filesystem/watcher"
|
||||
import { location } from "../test/fixture/location"
|
||||
import { catalogHost, host, integrationHost } from "../test/plugin/host"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string, fallback: number) => {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
if (index === -1) return fallback
|
||||
const value = Number(args[index + 1])
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
console.error(`--${name} must be a positive integer`)
|
||||
process.exit(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const locationCount = flag("locations", 6)
|
||||
const pluginCount = flag("plugins", 8)
|
||||
const jsonIndex = args.indexOf("--json")
|
||||
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
|
||||
|
||||
type Sample = { heapUsed: number; rss: number; objects: number }
|
||||
|
||||
const sample = (): Sample => {
|
||||
Bun.gc(true)
|
||||
Bun.gc(true)
|
||||
const usage = process.memoryUsage()
|
||||
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
|
||||
}
|
||||
|
||||
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
|
||||
|
||||
const median = (values: ReadonlyArray<number>) => {
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
const upper = sorted[middle] ?? 0
|
||||
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
|
||||
}
|
||||
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
|
||||
const globalLayer = Global.layerWith({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
cache: path.join(root, "cache"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
tmp: path.join(root, "tmp"),
|
||||
bin: path.join(root, "cache", "bin"),
|
||||
log: path.join(root, "data", "log"),
|
||||
repos: path.join(root, "data", "repos"),
|
||||
})
|
||||
const replacements = [
|
||||
Global.node.replace(globalLayer),
|
||||
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
]
|
||||
|
||||
// One full Location graph per directory, retained for the rest of the run, the
|
||||
// way a long-running server retains every directory a client has touched.
|
||||
const locationsProgram = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
const rss: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < locationCount; index++) {
|
||||
const directory = path.join(root, "projects", `location-${index}`)
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
const context = yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
|
||||
.pipe(Scope.provide(scope))
|
||||
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
|
||||
yield* plugins.awaitActivation
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
rss.push(current.rss)
|
||||
previous = current
|
||||
}
|
||||
const catalog = yield* Catalog.Service.pipe(
|
||||
Effect.provideContext(
|
||||
yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
|
||||
.pipe(Scope.provide(scope)),
|
||||
),
|
||||
)
|
||||
const models = yield* catalog.model.all()
|
||||
const providers = yield* catalog.provider.all()
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
rss,
|
||||
catalog: { providers: providers.length, models: models.length },
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
// The models.dev plugin alone, against a real Catalog and Integration state per
|
||||
// instance, isolates the catalog-copy contribution from the rest of the graph.
|
||||
const pluginProgram = Effect.gen(function* () {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const snapshot = yield* modelsDev.get()
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < pluginCount; index++) {
|
||||
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location(Location.Ref.make({ directory }))),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
|
||||
Location.node.replace(locationLayer),
|
||||
...replacements,
|
||||
]),
|
||||
).pipe(Scope.provide(scope))
|
||||
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
|
||||
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
|
||||
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
|
||||
yield* catalog.model.all()
|
||||
yield* integration.list()
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
previous = current
|
||||
}
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
snapshot: {
|
||||
providers: snapshot.length,
|
||||
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const plugin = yield* pluginProgram.pipe(
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
|
||||
)
|
||||
const locations = yield* locationsProgram.pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
|
||||
),
|
||||
)
|
||||
return { plugin, locations }
|
||||
}).pipe(Effect.provide(Logger.layer([])))
|
||||
|
||||
const result = await Effect.runPromise(program)
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
|
||||
|
||||
console.log(
|
||||
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
|
||||
)
|
||||
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
|
||||
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
|
||||
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
|
||||
console.log(
|
||||
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
|
||||
)
|
||||
console.log(
|
||||
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
|
||||
)
|
||||
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
|
||||
console.log(
|
||||
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
|
||||
)
|
||||
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
|
||||
|
||||
if (jsonPath) {
|
||||
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
jsonPath,
|
||||
JSON.stringify(
|
||||
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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* () {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -3,10 +3,11 @@ export * as ConfigPluginSource from "./source.js"
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { Location } from "../../location.js"
|
||||
@@ -41,8 +42,8 @@ export const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin entrypoints can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Configured local plugins can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those targets directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
@@ -56,7 +57,10 @@ export const layer = Layer.effect(
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: operation.target,
|
||||
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
|
||||
})
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -148,24 +152,40 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}
|
||||
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
|
||||
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
|
||||
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
|
||||
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
return Option.some<Operation>(operation)
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.orElseSucceed(() => operation),
|
||||
)
|
||||
})
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
|
||||
Effect.gen(function* () {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
|
||||
if (!(yield* fs.existsSafe(operation.target))) return [operation]
|
||||
const directory = yield* fs.isDir(operation.target)
|
||||
const entrypoints: Host.Entrypoints = directory
|
||||
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
|
||||
: { server: pathToFileURL(operation.target).href }
|
||||
if (!entrypoints.server) return []
|
||||
if (directory) {
|
||||
const root = yield* fs.resolve(operation.target)
|
||||
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
|
||||
if (!FSUtil.contains(root, server)) return []
|
||||
}
|
||||
const times = yield* Effect.forEach(
|
||||
[
|
||||
...Object.values(entrypoints)
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => fileURLToPath(entry)),
|
||||
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
|
||||
],
|
||||
(entry) =>
|
||||
fs.stat(entry).pipe(
|
||||
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
|
||||
Effect.orElseSucceed(() => 0),
|
||||
),
|
||||
)
|
||||
return [{ ...operation, mtime: Math.max(...times) }]
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flat()))
|
||||
})
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
}
|
||||
|
||||
+31
-22
@@ -20,7 +20,9 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
|
||||
// One slot per requested definition in activation order, including ones whose setup failed, so
|
||||
// the prefix diff below stays index-aligned and a failed revision is not retried until it changes.
|
||||
const active = new Map<Plugin.ID, Slot>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
@@ -52,7 +54,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
@@ -91,7 +92,7 @@ const layer = Layer.effect(
|
||||
if (entry) active.set(definition.id, { ...entry, plugin: definition })
|
||||
}
|
||||
if (prefix === definitions.length && active.size === definitions.length) {
|
||||
const nextInventory = [...definitions.map(activeInfo), ...failures]
|
||||
const nextInventory = [...Array.from(active.values()).map(slotInfo), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -104,33 +105,33 @@ const layer = Layer.effect(
|
||||
const previous = new Map(Array.from(active.entries()).slice(prefix))
|
||||
yield* Effect.forEach(
|
||||
Array.from(previous.entries()).toReversed(),
|
||||
([id, entry]) =>
|
||||
([id, slot]) =>
|
||||
Effect.gen(function* () {
|
||||
active.delete(id)
|
||||
yield* Scope.close(entry.scope, Exit.void)
|
||||
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const nextInventory = definitions.slice(0, prefix).map(activeInfo)
|
||||
for (const definition of definitions.slice(prefix)) {
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: definition, scope: loaded.scope },
|
||||
})
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
state: { status: "failed", error: loaded.error },
|
||||
features: { server: true, ...definition.features },
|
||||
})
|
||||
active.set(definition.id, { plugin: definition, error: loaded.error })
|
||||
|
||||
const fallback = previous.get(definition.id)
|
||||
const fallback = previous.get(definition.id)?.loaded
|
||||
if (!fallback) continue
|
||||
const restored = yield* load(fallback.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: fallback.plugin, scope: restored.scope })
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: fallback.plugin, scope: restored.scope },
|
||||
error: loaded.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -138,7 +139,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
inventory = [...nextInventory, ...failures]
|
||||
inventory = [...Array.from(active.values()).map(slotInfo), ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -167,12 +168,20 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Generation): Plugin.Info {
|
||||
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
|
||||
// running, which stays an older fallback while the requested revision keeps failing setup.
|
||||
type Slot = {
|
||||
readonly plugin: Generation
|
||||
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
function slotInfo(slot: Slot): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, ...plugin.features },
|
||||
id: Plugin.ID.make(slot.plugin.id),
|
||||
source: slot.plugin.source ?? { type: "builtin" },
|
||||
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
|
||||
features: { server: true, ...slot.plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user