Compare commits

...
Author SHA1 Message Date
Brendan Allan 461c42f757 refactor(app): restore explicit persistence initial state 2026-09-01 17:41:49 +08:00
Brendan Allan 322bf226ba test(app): remove leaking UI context mocks 2026-09-01 16:44:14 +08:00
Brendan Allan 66ed350fea fix(app): preserve persisted state compatibility 2026-09-01 16:34:37 +08:00
Brendan Allan e65e1ecf59 refactor(app): simplify persistence schema helpers 2026-09-01 16:17:40 +08:00
Brendan Allan d40b64a6bd refactor(app): drive persisted state with Effect Schema 2026-09-01 15:19:59 +08:00
opencode-agent[bot]andBrendonovich 71779ae5de fix(app): scope pane visibility to tabs (#46508)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 12:21:42 +08:00
Aiden Cline 4a3e25beab feat(codemode): compact schema constraint comments (#46521) 2026-08-31 23:06:49 -05:00
opencode-agent[bot]andrekram1-node 23fde448ec test(cli): isolate Bun define cache (#46536)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-31 23:01:02 -05:00
opencode-agent[bot] 1137861188 chore: update nix node_modules hashes 2026-09-01 04:00:15 +00:00
Dax Raad 815d4ab9b4 fix(tui): scope connect dialog to location 2026-08-31 23:42:35 -04:00
Dax Raad 5d73a5789f feat(plugin): support live package updates 2026-08-31 23:35:21 -04:00
Aiden Cline df05945042 chore(ci): align Bun with the release runtime (#46524) 2026-08-31 21:48:35 -05:00
Aiden Cline 6a99898ef7 feat(core): register tool namespaces (#46487) 2026-08-31 21:29:19 -05:00
a40a87276a fix(tui): pin diff highlights query (#46518)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Andreas Holt <6665487+AndreasHolt@users.noreply.github.com>
2026-08-31 21:06:43 -05:00
Luke Parker a20cbc394e feat(session-ui): preview images in read tool results (#46513) 2026-09-01 01:44:39 +00:00
Aiden Cline 8fda87614f feat(codemode): document numeric string and array constraints (#46510) 2026-08-31 20:44:07 -05:00
Kit Langton dffd95ce7c fix(codemode): reject Object.assign cycles (#46076) 2026-08-31 20:38:02 -04:00
opencode-agent[bot]andDavid Hill b0402f5a34 fix(session-ui): reduce inline code height (#46500)
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 08:26:46 +08:00
Kit Langton 54b00ec5fe test(tui): capture flushed Mini scrollback output (#46505) 2026-08-31 20:26:36 -04:00
Kit Langton 6dd1733bbf fix(core): preserve continuation across chained moves
Carry unfinished model work across consecutive Location handoffs without resetting the logical step allowance. Keep idle moves and queued prompt admission unchanged. Cover steered and queued second moves, preserved tool history, and durable event ordering.
2026-08-31 20:12:47 -04:00
opencode-agent[bot]andBrendonovich 663c2dc1ce fix(app): raise composer only in dark mode (#46503)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 08:12:09 +08:00
Kit Langton 01eda4c178 refactor(codemode): name only supported operations (#46082) 2026-08-31 20:00:13 -04:00
Kit Langton a6b49b3f74 fix(shell): preserve output from fast-exiting commands
Capture child stdout and stderr eagerly before lazy Effect readers attach. Preserve the bounded post-exit drain and process cleanup policies, with delayed-consumption and backpressure regressions.
2026-08-31 19:52:08 -04:00
opencode-agent[bot]andrekram1-node 5b2276666f test(tui): stop Windows image preview test crashes (#46479)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 18:31:47 -05:00
Kit Langton cc0cc59700 fix(ai): preserve done-only response messages (#46064) 2026-08-31 19:24:33 -04:00
Kit Langton 57a9decefe refactor(codemode): simplify input conflict detection (#46465) 2026-08-31 19:24:07 -04:00
Kit Langton c0220ddd8b refactor(codemode): reject unresolved intersections before rendering (#46468) 2026-08-31 19:23:52 -04:00
Kit Langton b31defc0a5 refactor(util): slice the final filename segment (#46466) 2026-08-31 19:23:35 -04:00
Kit Langton e7d42f83e6 refactor(core): slice model references at the first slash (#46467) 2026-08-31 19:23:21 -04:00
opencode-agent[bot]andBrendonovich db768c4886 refactor(app): share mobile drawer primitive (#46453)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 07:03:50 +08:00
Aiden Cline 9553187ba6 fix(core): allow patch file-to-directory replacements (#46476) 2026-08-31 16:24:04 -05:00
Aiden Cline d04257eeb4 feat(codemode): add inline namespace metadata (#46464) 2026-08-31 16:01:14 -05:00
opencode-agent[bot]andrekram1-node d68f425c17 feat(cli): port the upgrade command to v2 (#46183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 16:00:22 -05:00
Aiden Cline 49dd2cea34 refactor(ai): clarify responses adapters (#46469) 2026-08-31 15:36:57 -05:00
Simon Klee fac875dba0 mini v2 v2 (#46410) 2026-08-31 22:03:27 +02:00
Kit Langton 566ca864a0 refactor(util): reuse private patch buffers (#46459) 2026-08-31 15:11:54 -04:00
287 changed files with 14163 additions and 4813 deletions
+2
View File
@@ -969,6 +969,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -978,6 +979,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+2 -2
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+58 -43
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
export type HostedToolReplayItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| HostedToolReplayItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface Extension {
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,6 +397,9 @@ export interface ParserState {
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>>
}
@@ -482,12 +485,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -507,17 +510,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return lowered
})
@@ -526,13 +529,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
"tool-result",
)
})
@@ -540,30 +543,33 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -571,13 +577,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -644,7 +650,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
: adapter.restoreHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -658,13 +664,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -677,11 +681,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
output: yield* lowerToolResultOutput(part, request, adapter),
})
}
}
@@ -733,28 +737,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
input: yield* lowerMessages(request, adapter),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
extension.name,
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -768,7 +772,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
// =============================================================================
@@ -951,12 +955,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): 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]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -969,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1085,7 +1094,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : undefined
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1098,13 +1112,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: message ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1408,9 +1422,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1418,6 +1432,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
adapter,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -1,5 +1,16 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,10 +131,8 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -144,6 +142,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
}
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
source: { type: "package", target: id },
state: { status: "active" },
features: { server: true },
})),
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,37 +18,72 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -102,7 +137,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -114,8 +149,19 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
source: { type: "package", target: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{ terminalKey: terminalStorageKey(), ptyID },
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
)
}
@@ -16,10 +16,9 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeVisible()
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -120,7 +133,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -132,8 +145,20 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
@@ -11,12 +11,6 @@ beforeAll(async () => {
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./comments")
createCommentSessionForTest = mod.createCommentSessionForTest
})
+5 -17
View File
@@ -10,14 +10,9 @@ import { createScopedCache } from "@/runtime/server/scoped-cache"
import { uuid } from "@/runtime/persistence/uuid"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { CommentStore, type LineComment } from "./schema"
export type LineComment = {
id: string
file: string
selection: SelectedLineRange
comment: string
time: number
}
export type { LineComment } from "./schema"
type CommentFocus = { file: string; id: string }
@@ -37,10 +32,6 @@ function decodeSessionKey(key: string) {
}
}
type CommentStore = {
comments: Record<string, LineComment[]>
}
function aggregate(comments: Record<string, LineComment[]>) {
return Object.keys(comments)
.flatMap((file) => comments[file] ?? [])
@@ -179,12 +170,9 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
}
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "comments"),
createStore<CommentStore>({
comments: {},
}),
)
const [store, setStore, _, ready] = persisted(Persist.serverScoped(scope, dir, id, "comments"), CommentStore, {
comments: {},
})
const session = createCommentSessionState(store, setStore)
return {
@@ -1,3 +1,7 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+1 -3
View File
@@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { upgradeHistoryState } from "./store"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -58,7 +60,11 @@ describe("Composer history", () => {
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
expect(
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
entries: [text("stored")],
}),
).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
+3 -15
View File
@@ -1,24 +1,12 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export const MAX_HISTORY = 100
export type PromptHistoryComment = {
id: string
path: string
selection: SelectedLineRange
comment: string
time: number
origin?: "review" | "file"
preview?: string
}
export type PromptHistoryEntry = {
prompt: Prompt
comments: PromptHistoryComment[]
}
export type PromptHistoryStoredEntry = PromptHistoryEntry
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+9 -21
View File
@@ -1,4 +1,4 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
@@ -8,28 +8,14 @@ import {
type PromptHistoryStoredEntry,
} from "./entry"
import { clonePrompt } from "../prompt-parts"
import { PromptHistoryState } from "../schema"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
export function upgradeHistoryState(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
const entries = value.entries
if (!Array.isArray(entries)) return value
return {
...value,
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
return [entry as PromptHistoryStoredEntry]
}),
}
}
type PromptHistoryState = typeof PromptHistoryState.Type
function createComposerHistoryStore(
normal: Store<PromptHistoryState>,
@@ -51,12 +37,14 @@ function createComposerHistoryStore(
export function createComposerHistory() {
const [normal, setNormal, normalInit] = persisted(
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
Persist.prompt(Persist.global("prompt-history")),
PromptHistoryState,
{ entries: [] },
)
const [shell, setShell, shellInit] = persisted(
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
Persist.prompt(Persist.global("prompt-history-shell")),
PromptHistoryState,
{ entries: [] },
)
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
return {
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import {
CommentStore,
ComposerStore,
DEFAULT_PROMPT,
PromptHistoryState,
type TextPart,
type ImageAttachmentPart,
type FileAttachmentPart,
type LineComment,
} from "./schema"
const text: TextPart = { type: "text", content: "hello", start: 0, end: 5 }
const image: Omit<ImageAttachmentPart, "blob"> = {
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
}
const comment = { id: "comment", path: "src/app.ts", selection: { start: 1, end: 2 }, comment: "note", time: 1 }
describe("composer persistence schemas", () => {
test("defaults missing or invalid fields independently and normalizes the cursor", () => {
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)
expect(decode({})).toEqual({ prompt: DEFAULT_PROMPT, context: { items: [] } })
const value = decode({
prompt: [null, { type: "unknown" }],
cursor: -1,
mode: "unknown",
model: { providerID: 42, modelID: "model" },
retry: { id: "bad", agent: "build", providerID: "provider", modelID: "model" },
context: { items: [{ type: "file", path: "src/app.ts", commentID: "note", key: "stale" }, null] },
})
expect(value.prompt).toEqual(DEFAULT_PROMPT)
expect(value.cursor).toBe(0)
expect(value.mode).toBeUndefined()
expect(value.model).toBeUndefined()
expect(value.retry).toBeUndefined()
expect(value.context.items).toEqual([
{ type: "file", path: "src/app.ts", commentID: "note", key: "file:src/app.ts:undefined:undefined:c=note" },
])
expect(decode({ prompt: false, cursor: Infinity, context: null })).toEqual({
prompt: DEFAULT_PROMPT,
context: { items: [] },
})
const first = decode({})
first.prompt[0] = { type: "text", content: "changed", start: 0, end: 7 }
expect(decode({}).prompt).toEqual(DEFAULT_PROMPT)
})
test("drops invalid parts without losing valid mentions or optional field recovery", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: [
text,
{ type: "agent", content: "@build", start: 5, end: 11, name: "build" },
{ type: "skill", content: "@effect", start: 11, end: 18, id: "effect", name: "Effect" },
{ type: "agent", content: "@broken", start: 18, end: 25, name: 42 },
{
type: "file",
path: "src/app.ts",
content: "@src/app.ts",
start: 18,
end: 29,
selection: { startLine: "broken" },
mime: 42,
filename: "app.ts",
source: { type: "invalid" },
},
],
model: { providerID: "provider", modelID: "model", variant: null },
retry: { id: "msg_retry", agent: "build", providerID: "provider", modelID: "model", variant: false },
})
expect(value.prompt.map((part) => part.type)).toEqual(["text", "agent", "skill", "file"])
expect(value.prompt[3]).toEqual({
type: "file",
path: "src/app.ts",
content: "@src/app.ts",
start: 18,
end: 29,
filename: "app.ts",
})
expect(value.model?.variant).toBeNull()
expect(value.retry).toEqual({
id: SessionMessage.ID.make("msg_retry"),
agent: "build",
providerID: "provider",
modelID: "model",
})
expect(
Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)(Schema.encodeSync(ComposerStore)(value)),
).toEqual(value)
})
test("preserves file source variants through canonical round trips", () => {
const sourceText = { value: "@source", start: 0, end: 7 }
const sources: NonNullable<FileAttachmentPart["source"]>[] = [
{ type: "file", path: "src/app.ts", text: sourceText },
{ type: "resource", clientName: "docs", uri: "docs://example", text: sourceText },
{
type: "symbol",
path: "src/app.ts",
name: "App",
kind: 1,
range: { start: { line: 1, character: 0 }, end: { line: 2, character: 1 } },
text: sourceText,
},
]
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: sources.map((source) => ({
type: "file",
path: "src/app.ts",
content: "@source",
start: 0,
end: 7,
source,
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 1 },
})),
})
expect(value.prompt).toHaveLength(3)
expect(value.prompt.map((part) => part.type === "file" && part.source)).toEqual(sources)
expect(
Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)(Schema.encodeSync(ComposerStore)(value)),
).toEqual(value)
})
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: [
{ ...image, dataUrl: "data:image/png;base64,YQ==", sourcePath: "/image.png" },
{ ...image, blob: { id: "data:image/png;base64,Yg==" } },
{ ...image, blob: { id: "hash", url: "blob:hydrated" } },
{ ...image, blob: { id: "missing" } },
{ ...image, blob: { id: "bad", url: "https://example.com/image.png" } },
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
],
})
expect(value.prompt).toHaveLength(3)
expect(value.prompt[0]).toEqual({
...image,
sourcePath: "/image.png",
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
})
const encoded = Schema.encodeSync(ComposerStore)(value)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(
Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)(encoded),
).toEqual(value)
})
test("migrates legacy history arrays and recovers entries, parts, and comments independently", () => {
const value = Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
entries: [
[text, null, { ...image, dataUrl: "data:image/png;base64,YQ==" }],
null,
{},
{ prompt: false, comments: [] },
{ prompt: [text, { type: "invalid" }], comments: [comment, { ...comment, selection: null }, false] },
{ prompt: [text], comments: false },
],
})
expect(value.entries).toHaveLength(3)
expect(value.entries[0].prompt).toHaveLength(2)
expect(value.entries[0].comments).toEqual([])
expect(value.entries[1]).toEqual({ prompt: [text], comments: [comment] })
expect(value.entries[2]).toEqual({ prompt: [text], comments: [] })
const encoded = Schema.encodeSync(PromptHistoryState)(value)
expect(encoded.entries?.every((entry) => !Array.isArray(entry))).toBe(true)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))(encoded)).toEqual(
value,
)
expect(
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({ entries: "invalid" }),
).toEqual({ entries: [] })
})
test("recovers comments per file and entry without discarding healthy siblings", () => {
const line: LineComment = {
id: "comment",
file: "src/app.ts",
selection: { start: 1, end: 2, side: "additions", endSide: "deletions" },
comment: "note",
time: 1,
}
const value = Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({
comments: {
"src/app.ts": [line, null, { ...line, time: "bad" }, { ...line, selection: { start: 2, end: 4, side: "bad" } }],
"broken.ts": { invalid: true },
"healthy.ts": [{ ...line, file: "healthy.ts" }],
},
})
expect(value.comments["src/app.ts"]).toEqual([line, { ...line, selection: { start: 2, end: 4 } }])
expect(value.comments["broken.ts"]).toEqual([])
expect(value.comments["healthy.ts"]).toEqual([{ ...line, file: "healthy.ts" }])
expect(
Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))(
Schema.encodeSync(CommentStore)(value),
),
).toEqual(value)
expect(Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({})).toEqual({
comments: {},
})
expect(Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({ comments: [] })).toEqual(
{ comments: {} },
)
})
})
+212
View File
@@ -0,0 +1,212 @@
import { Schema, SchemaGetter } from "effect"
import { checksum } from "@opencode-ai/util/encode"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { Persistence } from "@/runtime/persistence/schema"
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
const PartBase = {
content: Schema.String,
start: Schema.Number,
end: Schema.Number,
}
const SourceText = Schema.Struct({ value: Schema.String, start: Schema.Number, end: Schema.Number })
const Position = Schema.Struct({ line: Schema.Number, character: Schema.Number })
const FilePartSource = Schema.Union([
Schema.Struct({ type: Schema.Literal("file"), text: SourceText, path: Schema.String }),
Schema.Struct({
type: Schema.Literal("symbol"),
text: SourceText,
path: Schema.String,
range: Schema.Struct({ start: Position, end: Position }),
name: Schema.String,
kind: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("resource"), text: SourceText, clientName: Schema.String, uri: Schema.String }),
])
export const TextPart = Persistence.struct({ type: Schema.Literal("text"), ...PartBase })
export type TextPart = typeof TextPart.Type
export const FileAttachmentPart = Persistence.struct({
type: Schema.Literal("file"),
...PartBase,
path: Schema.String,
selection: Persistence.optional(FileSelection),
mime: Persistence.optional(Schema.String),
filename: Persistence.optional(Schema.String),
url: Persistence.optional(Schema.String),
source: Persistence.optional(FilePartSource),
})
export type FileAttachmentPart = typeof FileAttachmentPart.Type
export const AgentPart = Persistence.struct({ type: Schema.Literal("agent"), ...PartBase, name: Schema.String })
export type AgentPart = typeof AgentPart.Type
export const SkillPart = Persistence.struct({
type: Schema.Literal("skill"),
...PartBase,
id: Skill.ID,
name: Skill.Name,
})
export type SkillPart = typeof SkillPart.Type
const ImageFields = {
type: Schema.Literal("image"),
id: Schema.String,
filename: Schema.String,
sourcePath: Persistence.optional(Schema.String),
mime: Schema.String,
}
const Image = Persistence.struct({
...ImageFields,
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
})
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
// inline data remains usable, but unresolved references are not renderable.
export const ImageAttachmentPart = Schema.Struct({
...ImageFields,
blob: Persistence.optional(
Schema.Struct({ id: Persistence.optional(Schema.String), url: Persistence.optional(Schema.String) }),
),
dataUrl: Persistence.optional(Schema.String),
}).pipe(
Schema.decodeTo(Schema.toType(Image), {
decode: SchemaGetter.transform((value) => {
const id = value.blob?.id ?? value.dataUrl ?? ""
const url = value.blob?.url
return {
type: value.type,
id: value.id,
filename: value.filename,
sourcePath: value.sourcePath,
mime: value.mime,
blob: {
id,
url: url?.startsWith("blob:") || url?.startsWith("data:") ? url : id.startsWith("data:") ? id : "",
},
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
export const PromptModel = Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
export type PromptModel = typeof PromptModel.Type
export const FileContextItem = Persistence.struct({
type: Schema.Literal("file"),
path: Schema.String,
selection: Persistence.optional(FileSelection),
comment: Persistence.optional(Schema.String),
commentID: Persistence.optional(Schema.String),
commentOrigin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
})
export type FileContextItem = typeof FileContextItem.Type
export type ContextItem = FileContextItem
export function contextItemKey(item: ContextItem) {
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
const ContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
Schema.decodeTo(Persistence.struct({ ...FileContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
decode: SchemaGetter.transform((item) => ({ ...item, key: contextItemKey(item) })),
encode: SchemaGetter.transform((item) => item),
}),
)
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const ComposerStore = Persistence.struct({
prompt: Prompt.pipe(
Schema.decode({
decode: SchemaGetter.transform((prompt) =>
prompt.length ? prompt : DEFAULT_PROMPT.map((part) => ({ ...part })),
),
encode: SchemaGetter.transform((prompt) => prompt),
}),
),
cursor: Persistence.optional(
Schema.Finite.pipe(
Schema.decode({
decode: SchemaGetter.transform((cursor) => Math.max(0, cursor)),
encode: SchemaGetter.transform((cursor) => cursor),
}),
),
),
model: Persistence.optional(PromptModel),
mode: Persistence.optional(Schema.Literals(["normal", "shell"])),
retry: Persistence.optional(
Schema.Struct({
id: SessionMessage.ID,
agent: Schema.String,
providerID: Schema.String,
modelID: Schema.String,
variant: Persistence.optional(Schema.String),
}),
),
context: Persistence.struct({ items: Persistence.array(ContextEntry) }),
})
export type ComposerStore = typeof ComposerStore.Type
export const LineComment = Persistence.struct({
id: Schema.String,
file: Schema.String,
selection: SelectedLineRange,
comment: Schema.String,
time: Schema.Number,
})
export type LineComment = typeof LineComment.Type
export const CommentStore = Persistence.struct({
comments: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(LineComment))),
})
export type CommentStore = typeof CommentStore.Type
export const PromptHistoryComment = Persistence.struct({
id: Schema.String,
path: Schema.String,
selection: SelectedLineRange,
comment: Schema.String,
time: Schema.Number,
origin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
})
export type PromptHistoryComment = typeof PromptHistoryComment.Type
// History entries require a prompt array; only its individual parts recover.
const HistoryPrompt = Schema.Array(Persistence.fallback(Schema.UndefinedOr(ContentPart), () => undefined)).pipe(
Schema.decodeTo(Schema.toType(Prompt), {
decode: SchemaGetter.transform((parts) => parts.filter((part) => part !== undefined)),
encode: SchemaGetter.transform((parts) => parts),
}),
)
const HistoryEntry = Schema.Struct({ prompt: HistoryPrompt, comments: Persistence.array(PromptHistoryComment) })
export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pipe(
Schema.decodeTo(Schema.toType(HistoryEntry), {
decode: SchemaGetter.transform((entry) => ("prompt" in entry ? entry : { prompt: entry, comments: [] })),
encode: SchemaGetter.transform((entry) => entry),
}),
)
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+8 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { Skill } from "@opencode-ai/schema/skill"
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
import { Schema, Option } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { createMemoryComposerState, DEFAULT_PROMPT } from "./state"
import { ComposerStore } from "./schema"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
@@ -29,7 +32,9 @@ describe("prompt state initialization", () => {
})
test("parses persisted state into one trusted current shape", () => {
const parsed = parseComposerStore({
const parsed = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
prompt: [
{ type: "text", content: "hello", start: 0, end: 5 },
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
@@ -105,6 +110,6 @@ describe("prompt state initialization", () => {
],
},
})
expect(parseComposerStore("not an object")).toBeUndefined()
expect(Option.isNone(Schema.decodeUnknownOption(ComposerStore)("not an object"))).toBe(true)
})
})
+33 -328
View File
@@ -1,127 +1,41 @@
import { checksum } from "@opencode-ai/util/encode"
import { batch, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/workspaces/files/model"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerScope } from "@/runtime/server/scope"
import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { clonePrompt } from "./prompt-parts"
import {
ComposerStore,
DEFAULT_PROMPT,
contextItemKey,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
} from "./schema"
interface PartBase {
content: string
start: number
end: number
}
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
ComposerStore,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
SkillPart,
TextPart,
} from "./schema"
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface SkillPart extends PartBase {
type: "skill"
id: Skill.ID
name: Skill.Name
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
blob: BlobReference
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | SkillPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export type ComposerStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
mode?: "normal" | "shell"
retry?: {
id: SessionMessage.ID
agent: string
providerID: string
modelID: string
variant?: string
}
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
@@ -147,16 +61,14 @@ function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
}
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
const target =
"draftID" in scope
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
return { ...target, migrate: parseComposerStore }
return "draftID" in scope
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
})
}
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
@@ -172,203 +84,6 @@ function initialComposerStore(initial?: InitialPrompt): ComposerStore {
}
}
export function parseComposerStore(value: unknown): ComposerStore | undefined {
if (!record(value)) return
const prompt = Array.isArray(value.prompt) ? value.prompt.flatMap(parsePart) : []
const context = record(value.context) && Array.isArray(value.context.items) ? value.context.items : []
const model = parseModel(value.model)
const retry = parseRetry(value.retry)
return {
prompt: prompt.length ? prompt : clonePrompt(DEFAULT_PROMPT),
...(typeof value.cursor === "number" && Number.isFinite(value.cursor) ? { cursor: Math.max(0, value.cursor) } : {}),
...(model ? { model } : {}),
...(value.mode === "normal" || value.mode === "shell" ? { mode: value.mode } : {}),
...(retry ? { retry } : {}),
context: {
items: context.flatMap((item) => {
const parsed = parseContextItem(item)
return parsed ? [{ ...parsed, key: contextItemKey(parsed) }] : []
}),
},
}
}
function parseRetry(value: unknown): ComposerStore["retry"] {
if (
!record(value) ||
typeof value.id !== "string" ||
!value.id.startsWith("msg_") ||
typeof value.agent !== "string" ||
typeof value.providerID !== "string" ||
typeof value.modelID !== "string"
) {
return
}
return {
id: SessionMessage.ID.make(value.id),
agent: value.agent,
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" ? { variant: value.variant } : {}),
}
}
function parsePart(value: unknown): ContentPart[] {
if (!record(value) || typeof value.type !== "string") return []
if (value.type === "image") {
const legacy = typeof value.dataUrl === "string" ? value.dataUrl : undefined
const blobID = record(value.blob) && typeof value.blob.id === "string" ? value.blob.id : legacy
const hydrated = record(value.blob) && typeof value.blob.url === "string" ? value.blob.url : undefined
const blobURL =
hydrated?.startsWith("blob:") || hydrated?.startsWith("data:")
? hydrated
: blobID?.startsWith("data:")
? blobID
: undefined
if (
typeof value.id !== "string" ||
typeof value.filename !== "string" ||
typeof value.mime !== "string" ||
!blobID ||
!blobURL
) {
return []
}
return [
{
type: "image",
id: value.id,
filename: value.filename,
mime: value.mime,
blob: { id: blobID, url: blobURL },
...(typeof value.sourcePath === "string" ? { sourcePath: value.sourcePath } : {}),
},
]
}
if (typeof value.content !== "string" || typeof value.start !== "number" || typeof value.end !== "number") return []
if (value.type === "text") return [{ type: "text", content: value.content, start: value.start, end: value.end }]
if (value.type === "agent" && typeof value.name === "string") {
return [{ type: "agent", name: value.name, content: value.content, start: value.start, end: value.end }]
}
if (value.type === "skill" && typeof value.id === "string" && typeof value.name === "string") {
return [
{
type: "skill",
id: Skill.ID.make(value.id),
name: Skill.Name.make(value.name),
content: value.content,
start: value.start,
end: value.end,
},
]
}
if (value.type !== "file" || typeof value.path !== "string") return []
const selection = parseSelection(value.selection)
const source = parseSource(value.source)
return [
{
type: "file",
path: value.path,
content: value.content,
start: value.start,
end: value.end,
...(typeof value.mime === "string" ? { mime: value.mime } : {}),
...(typeof value.filename === "string" ? { filename: value.filename } : {}),
...(typeof value.url === "string" ? { url: value.url } : {}),
...(selection ? { selection } : {}),
...(source ? { source } : {}),
},
]
}
function parseContextItem(value: unknown): ContextItem | undefined {
if (!record(value) || value.type !== "file" || typeof value.path !== "string") return
const selection = parseSelection(value.selection)
const origin = value.commentOrigin === "review" || value.commentOrigin === "file" ? value.commentOrigin : undefined
return {
type: "file",
path: value.path,
...(selection ? { selection } : {}),
...(typeof value.comment === "string" ? { comment: value.comment } : {}),
...(typeof value.commentID === "string" ? { commentID: value.commentID } : {}),
...(origin ? { commentOrigin: origin } : {}),
...(typeof value.preview === "string" ? { preview: value.preview } : {}),
}
}
function parseModel(value: unknown): PromptModel | undefined {
if (!record(value) || typeof value.providerID !== "string" || typeof value.modelID !== "string") return
return {
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" || value.variant === null ? { variant: value.variant } : {}),
}
}
function parseSelection(value: unknown): FileSelection | undefined {
if (!record(value)) return
if (
typeof value.startLine !== "number" ||
typeof value.startChar !== "number" ||
typeof value.endLine !== "number" ||
typeof value.endChar !== "number"
) {
return
}
return {
startLine: value.startLine,
startChar: value.startChar,
endLine: value.endLine,
endChar: value.endChar,
}
}
function parseSource(value: unknown): FilePartSource | undefined {
if (!record(value) || !record(value.text)) return
if (
typeof value.text.value !== "string" ||
typeof value.text.start !== "number" ||
typeof value.text.end !== "number"
) {
return
}
const text = { value: value.text.value, start: value.text.start, end: value.text.end }
if (value.type === "file" && typeof value.path === "string") return { type: "file", path: value.path, text }
if (value.type === "resource" && typeof value.clientName === "string" && typeof value.uri === "string") {
return { type: "resource", clientName: value.clientName, uri: value.uri, text }
}
if (
value.type !== "symbol" ||
typeof value.path !== "string" ||
typeof value.name !== "string" ||
typeof value.kind !== "number" ||
!record(value.range) ||
!record(value.range.start) ||
!record(value.range.end) ||
typeof value.range.start.line !== "number" ||
typeof value.range.start.character !== "number" ||
typeof value.range.end.line !== "number" ||
typeof value.range.end.character !== "number"
) {
return
}
return {
type: "symbol",
path: value.path,
name: value.name,
kind: value.kind,
text,
range: {
start: { line: value.range.start.line, character: value.range.start.character },
end: { line: value.range.end.line, character: value.range.end.character },
},
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
const actions = createComposerActions(setStore)
const clearRetry = () => setStore("retry", undefined)
@@ -442,11 +157,7 @@ function createPersistedComposer(
initial?: InitialPrompt,
platform?: Platform,
) {
const [store, setStore, _, ready] = persisted(
target,
createStore<ComposerStore>(initialComposerStore(initial)),
platform,
)
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
return { ready, ...createComposerStateValue(store, setStore) }
}
@@ -460,13 +171,7 @@ export function createComposerState(
}
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
return createPersistedComposer(
{
...Persist.prompt(Persist.draft(draftID, "prompt")),
migrate: parseComposerStore,
},
initial,
)
return createPersistedComposer(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
}
export type ComposerState = ReturnType<typeof createComposerState>
@@ -10,10 +10,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
@@ -22,10 +27,7 @@ export function createHomeProjectsController(home: HomeController) {
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const global = useGlobal()
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(promise) => promise.then(() => _state),
+14 -3
View File
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import type { ComposerModel } from "@/composer/model"
@@ -20,10 +20,19 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
})
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -31,7 +40,8 @@ export function NewSessionView(props: {
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
createStore({ used: false }),
WorkspaceOnboardingSchema,
{ used: false },
)
const select = (value: string) => {
props.workspace.selection.set(value)
@@ -110,7 +120,8 @@ function ProviderTip() {
const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
createStore({ dismissedAt: 0 }),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const visible = createMemo(
() =>
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
source: { type: "package", target: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+40 -32
View File
@@ -3,10 +3,12 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -15,39 +17,49 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
})
export type ModelKey = typeof ModelKeySchema.Type
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
type State = typeof StateSchema.Type
type Saved = {
session: Record<string, State | undefined>
}
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
)
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
Current,
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
}),
),
)
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return
return {
@@ -77,13 +89,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
migrate,
},
createStore<Saved>({
session: {},
}),
Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
ModelSelectionSchema,
{ session: {} },
)
const [store, setStore] = createStore<{
+19 -10
View File
@@ -1,16 +1,25 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
+22 -11
View File
@@ -1,6 +1,7 @@
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
I18nProvider,
@@ -11,6 +12,7 @@ import {
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import en from "@/runtime/i18n/en"
import { dict } from "@opencode-ai/ui/i18n/en"
import {
@@ -54,6 +56,16 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Schema.Struct({
locale: Schema.String.pipe(
Schema.decodeTo(LocaleSchema, {
decode: SchemaGetter.transform(normalizeLocale),
encode: SchemaGetter.transform((locale) => locale),
}),
),
})
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
const base = flatten({ ...en, ...dict })
@@ -148,17 +160,21 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
}
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
} catch {
return
}
@@ -182,14 +198,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
gate: false,
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language"),
createStore({
locale: initial,
}),
)
const [store, setStore, _, ready] = persisted(Persist.global("language"), languageSchema, { locale: initial })
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const locale = createMemo(() => store.locale)
const intl = createMemo(() => INTL[locale()])
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
@@ -0,0 +1,80 @@
# Persisted State
`persisted(target, schema, initial, platformOverride?)` creates a Solid store whose
type comes from an Effect Schema codec. The required `initial` value supplies
store defaults and is checked against that type. The function returns the store, setter, storage
initialization result, and readiness accessor. Both web and desktop use this
boundary, including cross-window updates.
```ts
const Preferences = Persistence.struct({
visible: Schema.Boolean,
mode: Schema.Literals(["normal", "shell"]),
directory: Persistence.optional(Schema.String),
recent: Persistence.array(Schema.String),
})
type Preferences = typeof Preferences.Type
const [preferences, setPreferences, , ready] = persisted(Persist.global("preferences"), Preferences, {
visible: true,
mode: "normal",
recent: [],
})
```
Keep initialization defaults in `initial`, not repeated across schema fields.
Plain struct fields recover independently from the corresponding initial value;
the resulting state is validated before entering the store. Arrays replace rather
than index-merge, explicit `null` is retained when allowed, and missing optional
values can inherit dynamic initial defaults.
Field codecs decode atomically, so the persistence layer does not attempt to
interpret arbitrary transformations. Collection-entry recovery and genuine
migration rules remain explicit in their schemas.
- `Persistence.fallback(schema, factory)` deliberately recovers invalid values as
well as missing or undefined input. Use it for domain-specific recovery, such as
defaults inside collection entries, not ordinary store initialization.
- `Persistence.optional(schema)` omits invalid fields as well as accepting missing
or undefined input. Use ordinary `Schema.optional` when no codec-local recovery
is needed; the initialized store boundary still recovers fields from `initial`.
- `Persistence.struct(fields)` makes fields mutable for Solid stores while preserving
each field's optionality and codec. It does not add defaults or error recovery.
- `Persistence.record(valueSchema)` creates a mutable string-keyed record, defaulting
missing or invalid records to a fresh `{}`. Its value schema determines entry
recovery: pass `Persistence.optional(valueSchema)` to discard only invalid entries,
or `Persistence.fallback(valueSchema, factory)` to replace those entries.
- `Persistence.array(schema)` defaults to an empty mutable array and discards
invalid entries individually. Valid entries still pass through their codecs.
- Recovery is not a substitute for an explicit historical shape transformation.
## Migrations
Describe shipped representations with schemas and transform their typed values
using `Schema.decode` or `Schema.decodeTo` and `SchemaGetter`. For whole-object
migrations, pass `Persistence.migrate(currentSchema, storedCodec)` instead of the
plain schema. The stored codec runs before defaults are applied, preserving
distinctions such as an absent current field identifying an older format. It
returns a candidate in the current encoded shape; the current schema then owns
recovery and validation.
The migration reader preserves excess properties so a migration can describe
only the fields it observes without dropping unrelated saved preferences. The
current schema strips fields outside its contract. Writes use only the current
schema's encoder, never the legacy reader's encoder.
`Persistence.withInitial(schemaOrMigration, initial)` exposes the same initialized
codec for focused tests. Test canonical encoding and decode/encode/decode stability
as well as historical fixtures.
Reads normalize stored JSON through decoding and encoding, writing back the
canonical representation when it changed. Invalid documents fall back to initial
state; malformed individual values can instead be recovered by their schemas.
Cross-window values are decoded before entering the store. Writes use the same
codec's encoder.
Storage-key relocation (`previousKey`, workspace aliases, draft storage moves)
remains separate from schema migration. Draft blob externalization and hydration
also remain in the storage adapter: composer codecs receive hydrated references,
not raw ID-only blob documents.
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
expect(tip({})).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
collapsed: { open: false, closed: true, invalid: false },
})
}
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
expect(decode({})).toEqual({ session: {} })
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
expect(state.session.session1?.agent).toBe("build")
expect(state.session.__workspace__).toBeUndefined()
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(state)
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
expect(decode(JSON.parse(encoded))).toEqual(state)
})
test("current model selections take precedence over legacy picks", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {},
pick: { session1: { agent: "plan" } },
}),
).toEqual({ session: {} })
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
invalid: "build",
},
})
expect(state.session.good).toEqual({
agent: "build",
model: { providerID: "provider", modelID: "model", variant: "high" },
variant: null,
})
expect(state.session.partial?.agent).toBe("plan")
expect(state.session.partial?.model).toBeUndefined()
expect(state.session.partial?.variant).toBeUndefined()
expect(state.session.invalid).toBeUndefined()
})
test("file views validate scroll positions and line sides independently", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
good: {
scrollTop: 12,
scrollLeft: 4,
selectedLines: { start: 9, end: 2, side: "deletions", endSide: "additions" },
},
partial: { scrollTop: "12", scrollLeft: 8, selectedLines: { start: 1, end: 3, side: "invalid" } },
cleared: { selectedLines: null },
invalid: false,
},
})
expect(state.file.good).toEqual({
scrollTop: 12,
scrollLeft: 4,
selectedLines: { start: 9, end: 2, side: "deletions", endSide: "additions" },
})
expect(state.file.partial?.scrollTop).toBeUndefined()
expect(state.file.partial?.scrollLeft).toBe(8)
expect(state.file.partial?.selectedLines).toEqual({ start: 1, end: 3 })
expect(state.file.cleared?.selectedLines).toBeNull()
expect(state.file.invalid).toEqual({})
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
expect(decode({ locale: "unsupported" })).toEqual({ locale: "en" })
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,4 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Option, Schema } from "effect"
export type BlobReference = { id: string; url: string }
@@ -81,7 +82,11 @@ export function createDraftStore(driver: Driver): DraftStore {
return {
getItem: async (key) => {
const value = await driver.get(key)
return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
if (value === null) return null
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
return JSON.stringify(await decode(parsed.value))
},
setItem: async (key, value) => {
const version = (versions.get(key) ?? 0) + 1
@@ -0,0 +1,162 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import { Persistence } from "./schema"
describe("persistence schemas", () => {
test("initial state supplies nested defaults without hiding valid siblings", () => {
const schema = Persistence.withInitial(
Persistence.struct({
enabled: Schema.Boolean,
appearance: Persistence.struct({ width: Schema.Number, font: Schema.String }),
variant: Schema.optional(Schema.NullOr(Schema.String)),
}),
{ enabled: true, appearance: { width: 240, font: "default" }, variant: "high" },
)
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ appearance: { width: 300 } })).toEqual({
enabled: true,
appearance: { width: 300, font: "default" },
variant: "high",
})
expect(decode({ enabled: "false", appearance: { width: "wide", font: "saved" }, variant: null })).toEqual({
enabled: true,
appearance: { width: 240, font: "saved" },
variant: null,
})
expect(decode({ appearance: null })).toEqual({
enabled: true,
appearance: { width: 240, font: "default" },
variant: "high",
})
})
test("legacy migration observes missing fields before initial defaults are applied", () => {
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
const stored = Schema.Struct({
mode: Schema.optional(Schema.Unknown),
expanded: Schema.optional(Schema.Boolean),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) =>
value.mode !== undefined || value.expanded === undefined
? value
: { ...value, mode: value.expanded ? "full" : "compact" },
),
encode: SchemaGetter.passthrough(),
}),
)
const schema = Persistence.withInitial(Persistence.migrate(current, stored), { mode: "compact", enabled: true })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ expanded: true, enabled: false })).toEqual({ mode: "full", enabled: false })
expect(decode({ expanded: true, mode: "compact" })).toEqual({ mode: "compact", enabled: true })
expect(decode({ expanded: true, mode: "invalid" })).toEqual({ mode: "compact", enabled: true })
expect(Schema.encodeSync(schema)(decode({ expanded: true }))).toEqual({ mode: "full", enabled: true })
})
test("initial merging preserves field codecs and replaces arrays rather than merging indexes", () => {
const current = Persistence.struct({
amount: Schema.NumberFromString.check(Schema.isFinite()),
items: Schema.mutable(Schema.Array(Schema.String)),
})
const schema = Persistence.withInitial(current, { amount: 7, items: ["initial"] })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ amount: "12", items: [] })).toEqual({ amount: 12, items: [] })
expect(decode({ amount: "invalid", items: ["saved"] })).toEqual({ amount: 7, items: ["saved"] })
expect(Schema.encodeSync(schema)(decode({}))).toEqual({ amount: "7", items: ["initial"] })
})
test("built-in defaults only recover absent values, not invalid ones", () => {
const schema = Persistence.struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true })
expect(decode({ enabled: undefined })).toEqual({ enabled: true })
expect(() => decode({ enabled: "true" })).toThrow()
const state: typeof schema.Type = { enabled: false }
state.enabled = true
expect(Schema.encodeSync(schema)(state)).toEqual({ enabled: true })
})
test("defaults missing and invalid fields without discarding valid siblings", () => {
const schema = Schema.Struct({
enabled: Persistence.fallback(Schema.Boolean, () => true),
label: Persistence.fallback(Schema.String, () => "default"),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true, label: "default" })
expect(decode({ enabled: "false", label: "saved" })).toEqual({ enabled: true, label: "saved" })
expect(decode({ enabled: undefined, label: null })).toEqual({ enabled: true, label: "default" })
expect(Schema.encodeSync(schema)(decode({}))).toEqual({ enabled: true, label: "default" })
})
test("optional recovery keeps fields optional without adding an undefined default", () => {
const number = Schema.NumberFromString.check(Schema.isFinite())
const schema = Persistence.struct({ value: Persistence.optional(number) })
const decode = Schema.decodeUnknownSync(schema)
const empty: typeof schema.Type = {}
expect(decode({})).toEqual(empty)
expect(decode({ value: "invalid" })).toEqual(empty)
expect(Object.hasOwn(decode({ value: "invalid" }), "value")).toBe(false)
expect(decode({ value: undefined })).toEqual({ value: undefined })
expect(decode({ value: "42" })).toEqual({ value: 42 })
expect(Schema.encodeSync(schema)({ value: 42 })).toEqual({ value: "42" })
expect(Schema.encodeSync(schema)(empty)).toEqual({})
expect(() =>
Schema.decodeUnknownSync(Schema.Struct({ value: Schema.optional(number) }))({ value: "invalid" }),
).toThrow()
})
test("fallbacks use decoded values and retain the codec on writes", () => {
const schema = Persistence.struct({
value: Persistence.fallback(Schema.NumberFromString.check(Schema.isFinite()), () => 7),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ value: 7 })
expect(decode({ value: "invalid" })).toEqual({ value: 7 })
expect(Schema.encodeSync(schema)(decode({}))).toEqual({ value: "7" })
})
test("records default to fresh mutable objects and keep entry recovery explicit", () => {
const strict = Persistence.record(Schema.Boolean)
const decode = Schema.decodeUnknownSync(strict)
expect(decode(undefined)).toEqual({})
expect(decode([])).toEqual({})
expect(decode({ valid: true, invalid: "true" })).toEqual({})
const first: typeof strict.Type = decode(undefined)
first.changed = true
expect(decode(undefined)).toEqual({})
const recover = Persistence.record(Persistence.optional(Schema.Boolean))
const state = Schema.decodeUnknownSync(recover)({ valid: true, invalid: "true" })
expect(state).toEqual({ valid: true })
expect(Schema.encodeSync(recover)(state)).toEqual({ valid: true })
})
test("creates fresh default collections", () => {
const schema = Schema.Struct({ items: Persistence.array(Schema.String) })
const decode = Schema.decodeUnknownSync(schema)
const first = decode({})
first.items.push("changed")
expect(decode({}).items).toEqual([])
})
test("recovers and migrates individual array entries", () => {
const current = Schema.Struct({ name: Schema.String })
const schema = Persistence.array(
Schema.Union([current, Schema.String]).pipe(
Schema.decodeTo(current, {
decode: SchemaGetter.transform((value) => (typeof value === "string" ? { name: value } : value)),
encode: SchemaGetter.passthrough(),
}),
),
)
const decode = Schema.decodeUnknownSync(schema)
const value = decode(["old", { name: "new" }, null, { name: false }])
expect(value).toEqual([{ name: "old" }, { name: "new" }])
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode(undefined)).toEqual([])
expect(decode({})).toEqual([])
})
})
@@ -0,0 +1,99 @@
export * as Persistence from "./schema"
import { Effect, Option, Predicate, Result, Schema, SchemaAST, SchemaGetter, SchemaParser, Struct } from "effect"
export type Migrated<S extends Schema.ConstraintCodec<object, unknown>> = {
current: S
read: Schema.ConstraintDecoder<unknown>
}
export function migrate<S extends Schema.ConstraintCodec<object, unknown>>(
current: S,
read: Schema.ConstraintDecoder<unknown>,
): Migrated<S> {
return { current, read }
}
function isMigrated<S extends Schema.ConstraintCodec<object, unknown>>(schema: S | Migrated<S>): schema is Migrated<S> {
return "current" in schema
}
export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
definition: S | Migrated<S>,
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
: Result.succeed<unknown>
const encode = Schema.encodeUnknownSync(schema)
return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
decode: SchemaGetter.transformOrFail((value) =>
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
),
encode: SchemaGetter.transform((value) => encode(value)),
}),
Schema.decodeTo(Schema.toType(schema)),
)
}
// Object-level codecs own their recovery. Plain structs can recover fields independently.
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
return Object.fromEntries(
ast.propertySignatures.flatMap((field) => {
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
const next = recover(field.type, value[field.name], defaults)
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
return [[field.name, next]]
}),
)
}
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
return Option.isSome(decoded) ? decoded.value : initial
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
// Unlike a decoding default, a fallback also replaces invalid persisted values.
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
}
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const field = Schema.optional(schema)
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
}
export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields) {
return Schema.Struct(fields).mapFields(Struct.map(Schema.mutableKey))
}
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
}
// Recover individual entries rather than discarding a whole history or collection.
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const decode = Schema.decodeUnknownOption(schema)
const encode = Schema.encodeSync(schema)
return fallback(
Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
decode: SchemaGetter.transform((items) => items.flatMap((item) => Option.toArray(decode(item)))),
encode: SchemaGetter.transform((items) => items.map((item) => encode(item))),
}),
),
() => [],
)
}
@@ -107,11 +107,6 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem("direct-value")).toBe('{"value":5}')
})
test("normalizer rejects malformed JSON payloads", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
test("workspace storage sanitizes Windows filename characters", () => {
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
+31 -80
View File
@@ -2,9 +2,11 @@ import { Platform, usePlatform } from "@/runtime/platform/platform"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "./schema"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
@@ -22,7 +24,6 @@ type PersistTarget = {
workspaceStorageAliases?: string[]
previousKey?: string
key: string
migrate?: (value: unknown) => unknown
}
const GLOBAL_STORAGE = "opencode.global.dat"
@@ -164,65 +165,10 @@ function write(storage: Storage, key: string, value: string) {
return ok
}
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
const parsed = parse(raw)
if (parsed === undefined) return
const migrated = migrate ? migrate(parsed) : parsed
const merged = merge(defaults, migrated)
return JSON.stringify(merged)
}
function readCurrent(input: {
storage: SyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
function readCurrent(input: { storage: SyncStorage; key: string; normalize: (raw: string) => string | undefined }) {
const raw = input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
input.storage.removeItem(input.key)
return null
@@ -235,15 +181,14 @@ function relocateStoredValue(input: {
current: SyncStorage
sources: { storage: SyncStorage; key?: string }[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
normalize: (raw: string) => string | undefined
}) {
for (const source of input.sources) {
const key = source.key ?? input.key
const raw = source.storage.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
source.storage.removeItem(key)
continue
@@ -259,12 +204,11 @@ function relocateStoredValue(input: {
async function readCurrentAsync(input: {
storage: AsyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
normalize: (raw: string) => string | undefined
}) {
const raw = await input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
await input.storage.removeItem(input.key).catch(() => undefined)
return null
@@ -291,15 +235,14 @@ async function relocateStoredValueAsync(input: {
current: AsyncStorage
sources: { storage: AsyncStorage; key?: string }[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
normalize: (raw: string) => string | undefined
}) {
for (const source of input.sources) {
const key = source.key ?? input.key
const raw = await source.storage.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
const next = input.normalize(raw)
if (next === undefined) {
await removeAsync(source.storage, key)
continue
@@ -446,7 +389,6 @@ export function draftPersistedKeys() {
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
resolveTarget,
windowStorage,
workspaceStorage,
@@ -529,15 +471,24 @@ export function removePersisted(
}
}
export function persisted<T>(
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
schema: S | Persistence.Migrated<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<T> {
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const defaults = snapshot(store[0])
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const serialize = Schema.encodeSync(json)
const normalize = (raw: string) => {
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
}
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
@@ -567,14 +518,13 @@ export function persisted<T>(
const api: SyncStorage = {
getItem: (key) => {
const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
const value = readCurrent({ storage: current, key, normalize })
if (value !== undefined) return value
return relocateStoredValue({
current,
sources,
key,
defaults,
migrate: config.migrate,
normalize,
})
},
setItem: (key, value) => {
@@ -610,14 +560,13 @@ export function persisted<T>(
const api: AsyncStorage = {
getItem: async (key) => {
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
const value = await readCurrentAsync({ storage: current, key, normalize })
if (value !== undefined) return value
const relocated = await relocateStoredValueAsync({
current,
sources: relocationSources,
key,
defaults,
migrate: config.migrate,
normalize,
})
if (draftLatest === undefined) {
if (draft && relocated !== null) return (await current.getItem(key)) ?? relocated
@@ -644,9 +593,11 @@ export function persisted<T>(
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted<T, typeof store>(store, {
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
})
@@ -5,12 +5,12 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../sync"
import { ServerScope } from "@/runtime/server/scope"
import type { Data } from "@opencode-ai/client/solid"
import type { persisted } from "@/runtime/persistence/storage"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const persist: typeof import("@/runtime/persistence/storage").persisted = (_target, store) => [
store[0],
store[1],
const persist: typeof persisted = (_target, _schema, initial) => [
...createStore(initial),
null,
Object.assign(() => true, { promise: undefined }),
]
@@ -20,6 +20,7 @@ import { directoryKey, type DirectoryKey } from "./utils"
import type { ServerScope } from "@/runtime/server/scope"
import type { Data } from "@opencode-ai/client/solid"
import { normalizeAgentList, normalizeProviderList } from "./utils"
import { IconState, ProjectState, VcsState } from "../persistence"
export function createChildStoreManager(input: {
owner: Owner
@@ -155,29 +156,20 @@ export function createChildStoreManager(input: {
if (!key) console.error("No directory provided")
if (!children[key]) {
const vcs = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "vcs"),
createStore({ value: undefined as VcsInfo | undefined }),
),
input.persist(Persist.serverWorkspace(input.scope, directory, "vcs"), VcsState, { value: undefined }),
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "project"),
createStore({ value: undefined as ProjectMeta | undefined }),
),
input.persist(Persist.serverWorkspace(input.scope, directory, "project"), ProjectState, { value: undefined }),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "icon"),
createStore({ value: undefined as string | undefined }),
),
input.persist(Persist.serverWorkspace(input.scope, directory, "icon"), IconState, { value: undefined }),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
@@ -3,17 +3,9 @@ import type { ReferenceInfo } from "@opencode-ai/client/promise"
import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { IconState, ProjectState, VcsState } from "../persistence"
export type ProjectMeta = {
name?: string
icon?: {
override?: string
color?: string
}
commands?: {
start?: string
}
}
export type ProjectMeta = NonNullable<typeof ProjectState.Type.value>
export type State = {
status: "loading" | "partial" | "complete"
@@ -40,20 +32,20 @@ export type State = {
}
export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
store: Store<typeof VcsState.Type>
setStore: SetStoreFunction<typeof VcsState.Type>
ready: Accessor<boolean>
}
export type MetaCache = {
store: Store<{ value: ProjectMeta | undefined }>
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
store: Store<typeof ProjectState.Type>
setStore: SetStoreFunction<typeof ProjectState.Type>
ready: Accessor<boolean>
}
export type IconCache = {
store: Store<{ value: string | undefined }>
setStore: SetStoreFunction<{ value: string | undefined }>
store: Store<typeof IconState.Type>
setStore: SetStoreFunction<typeof IconState.Type>
ready: Accessor<boolean>
}
@@ -0,0 +1,266 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
import { createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
function serverSchema(canonical?: () => string | undefined) {
return Persistence.withInitial(serverState(canonical), initial)
}
describe("server persistence schema", () => {
test("migrates legacy auth and writes only current server objects", () => {
const schema = serverSchema()
const input = {
list: [
"http://localhost:4096",
{ url: "https://flat.example", username: "legacy", password: "first" },
{
type: "http",
displayName: "Remote",
label: "Production",
authToken: true,
http: { url: "https://nested.example", username: "legacy", password: "second" },
},
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
const state = Schema.decodeUnknownSync(schema)(input)
expect(state).toEqual({
list: [
{ type: "http", http: { url: "http://localhost:4096" } },
{ type: "http", http: { url: "https://flat.example", password: "first" } },
{
type: "http",
displayName: "Remote",
label: "Production",
authToken: true,
http: { url: "https://nested.example", password: "second" },
},
],
hidden: {},
projects: input.projects,
lastProject: {},
recentlyClosed: {},
})
expect(input.list[1]).toHaveProperty("username", "legacy")
const encoded = Schema.encodeSync(schema)(state)
expect(encoded).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
})
test("defaults missing or malformed fields and drops invalid entries independently", () => {
const decode = Schema.decodeUnknownSync(serverSchema())
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
expect(decode({})).toEqual(empty)
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
expect(
decode({
list: [null, 1, {}, { type: "http", http: { url: 12 } }, "https://valid.example"],
projects: { local: [null, {}, { worktree: 1 }, { worktree: "/project" }], remote: false },
recentlyClosed: { local: [null, 1, "/closed"], remote: null },
}),
).toEqual({
...empty,
list: [{ type: "http", http: { url: "https://valid.example" } }],
projects: { local: [{ worktree: "/project", expanded: true }], remote: [] },
recentlyClosed: { local: ["/closed"], remote: [] },
})
})
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
const schema = serverSchema(() => "https://opencode.example.com")
const state = Schema.decodeUnknownSync(schema)({
list: ["https://opencode.example.com"],
hidden: { "https://opencode.example.com": true },
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
{ worktree: "/remote", expanded: false },
],
other: [{ worktree: "/other", expanded: true }],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote", other: "/other" },
recentlyClosed: { local: ["/closed"], "https://opencode.example.com": ["/old-closed"] },
})
expect(state.projects).toEqual({
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
other: [{ worktree: "/other", expanded: true }],
})
expect(state.lastProject).toEqual({ local: "/local", other: "/other" })
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
expect(Schema.encodeSync(schema)(state)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
})
test("reads the latest canonical local prop on each decode", () => {
const props: { canonicalLocalServer?: string } = {}
const schema = serverSchema(() => props.canonicalLocalServer)
const decode = Schema.decodeUnknownSync(schema)
const input = {
projects: { remote: [{ worktree: "/project", expanded: true }] },
lastProject: { remote: "/project" },
}
expect(decode(input).projects).toEqual(input.projects)
props.canonicalLocalServer = "remote"
expect(decode(input).projects).toEqual({ local: [{ worktree: "/project", expanded: true }] })
expect(decode(input).lastProject).toEqual({ local: "/project" })
props.canonicalLocalServer = "local"
expect(decode(input).projects).toEqual(input.projects)
expect(input.lastProject).toEqual({ remote: "/project" })
})
test("migrates a last project without a project list", () => {
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
list: [],
hidden: {},
projects: {},
lastProject: { local: "/project" },
recentlyClosed: {},
})
})
})
describe("model persistence schema", () => {
test("defaults missing state and keeps valid entries beside malformed entries", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
const state = decode({
user: [
null,
{ providerID: "provider", modelID: "model", visibility: "show", favorite: true },
{ providerID: "provider", modelID: "invalid", visibility: "invalid" },
{ providerID: "provider", modelID: "hidden", visibility: "hide" },
],
recent: [false, { providerID: "provider", modelID: "model" }, { providerID: "missing-model" }],
variant: { model: "high" },
})
expect(state).toEqual({
user: [
{ providerID: "provider", modelID: "model", visibility: "show", favorite: true },
{ providerID: "provider", modelID: "hidden", visibility: "hide" },
],
recent: [{ providerID: "provider", modelID: "model" }],
variant: { model: "high" },
})
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
})
})
describe("directory cache schemas", () => {
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
})
test("validates project name, icon overrides and startup commands", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: [] })).toEqual({ value: undefined })
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
expect(decode({ value: { commands: { start: false } } })).toEqual({ value: undefined })
expect(decode({ value: {} })).toEqual({ value: {} })
const state = decode({
value: {
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
commands: { start: "bun dev" },
},
})
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
expect(state.value).toEqual({
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
commands: { start: "bun dev" },
})
})
test("validates optional icon strings", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: 42 })).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: "" })).toEqual({ value: "" })
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
value: "data:image/png;base64,abc",
})
})
})
test.skipIf(isServer)(
"persisted server relocation hydrates migrated state and writes current schema on updates",
async () => {
const values = new Map([
[
"default:server.v3",
JSON.stringify({
list: [{ url: "https://remote.example", username: "legacy", password: "secret" }],
projects: { "https://remote.example": [{ worktree: "/project", expanded: false }] },
lastProject: { "https://remote.example": "/project" },
}),
],
])
const root = createRoot((dispose) => ({
dispose,
state: persisted(
{ ...Persist.global("server"), previousKey: "server.v3" },
serverState(() => "https://remote.example"),
initial,
{
platform: "desktop",
windowID: "test",
openExternal() {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
storage: (name = "default") => ({
getItem: async (key) => values.get(`${name}:${key}`) ?? null,
setItem: async (key, value) => {
values.set(`${name}:${key}`, value)
},
removeItem: async (key) => {
values.delete(`${name}:${key}`)
},
}),
},
),
}))
try {
await root.state[3].promise
expect(values.has("default:server.v3")).toBe(false)
expect(root.state[0].list).toEqual([
{ type: "http", http: { url: "https://remote.example", password: "secret" } },
])
expect(root.state[0].projects).toEqual({ local: [{ worktree: "/project", expanded: false }] })
expect(root.state[0].lastProject).toEqual({ local: "/project" })
root.state[1]("projects", "local", 0, "expanded", true)
const stored = values.get("opencode.global.dat:server")
expect(stored).toBeDefined()
if (!stored) throw new Error("server state was not written")
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
expect(stored).not.toContain("username")
expect(decoded.list).toEqual(root.state[0].list)
} finally {
root.dispose()
}
},
)
@@ -0,0 +1,136 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
export const ServerHttpBase = Persistence.struct({
url: Schema.String,
password: Schema.optional(Schema.String),
})
export const ServerHttp = Persistence.struct({
type: Schema.Literal("http"),
http: ServerHttpBase,
authToken: Schema.optional(Schema.Boolean),
displayName: Schema.optional(Schema.String),
label: Schema.optional(Schema.String),
})
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
}),
)
const ProjectList = Persistence.array(
Persistence.struct({
worktree: Schema.String,
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
list: Persistence.array(StoredServer),
hidden: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
lastProject: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
})
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Persistence.migrate(
State,
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
}),
),
)
}
export const ModelState = Persistence.struct({
user: Persistence.array(
Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
visibility: Schema.Literals(["show", "hide"]),
favorite: Schema.optional(Schema.Boolean),
}),
),
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
variant: Schema.Record(
Schema.String,
Schema.mutableKey(
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
),
})
export const VcsState = Persistence.struct({
value: Schema.optional(
Persistence.struct({
branch: Schema.optional(Schema.String),
default_branch: Schema.optional(Schema.String),
}),
),
})
const ProjectMeta = Persistence.struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(
Persistence.struct({
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
}),
),
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
})
export const ProjectState = Persistence.struct({
value: Schema.optional(ProjectMeta),
})
export const IconState = Persistence.struct({
value: Schema.optional(Schema.String),
})
@@ -1,50 +1,25 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import {
migrateCanonicalLocalServerState,
migrateServerAuthState,
resolveServerList,
ServerConnection,
} from "./registry"
import { ServerScope } from "@/runtime/server/scope"
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
import { Schema } from "effect"
import { serverState } from "./persistence"
import { createStore } from "solid-js/store"
import { ServerScope } from "./scope"
import { Persistence } from "@/runtime/persistence/schema"
describe("migrateServerAuthState", () => {
test("removes legacy usernames without changing passwords or other saved state", () => {
const state = {
list: [
"http://localhost:4096",
{ url: "https://flat.example", username: "legacy", password: "first" },
{
type: "http",
displayName: "Remote",
http: { url: "https://nested.example", username: "legacy", password: "second" },
},
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
expect(migrateServerAuthState(state)).toEqual({
...state,
list: [
"http://localhost:4096",
{ url: "https://flat.example", password: "first" },
{ type: "http", displayName: "Remote", http: { url: "https://nested.example", password: "second" } },
],
})
expect(state.list[1]).toHaveProperty("username", "legacy")
expect(migrateServerAuthState(migrateServerAuthState(state))).toEqual(migrateServerAuthState(state))
function serverSchema() {
return Persistence.withInitial(serverState(), {
list: [],
hidden: {},
projects: {},
lastProject: {},
recentlyClosed: {},
})
test("preserves absent or malformed lists", () => {
expect(migrateServerAuthState(undefined)).toBeUndefined()
expect(migrateServerAuthState({ projects: {} })).toEqual({ projects: {} })
expect(migrateServerAuthState({ list: [null, 1, {}] })).toEqual({ list: [null, 1, {}] })
})
})
}
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: [{ url: "https://server.example.test" }],
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
props: [
{
type: "http",
@@ -64,17 +39,14 @@ describe("resolveServerList", () => {
password: "secret",
})
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
expect(list[0] && String(ServerConnection.key(list[0]))).toBe("https://server.example.test")
})
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: [
{
url: "https://server.example.test",
password: "saved",
},
],
stored: Schema.decodeUnknownSync(serverSchema())({
list: [{ url: "https://server.example.test", password: "saved" }],
}).list,
props: [{ type: "http", http: { url: "https://server.example.test" } }],
})
@@ -104,47 +76,42 @@ test("treats WSL sidecars as remote server connections", () => {
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
})
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
migrateCanonicalLocalServerState(
{
list: [],
projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
lastProject: { "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
list: [],
projects: { local: [{ worktree: "/remote", expanded: true }] },
lastProject: { local: "/remote" },
})
})
test("keeps exact persisted server identities and prevents removing provided servers", () => {
const stored = Schema.decodeUnknownSync(serverSchema())({
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
}).list
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
"http://localhost:4096",
"http://localhost:4096/",
"http://127.0.0.1:4096",
])
const key = ServerConnection.Key.make("http://localhost:4096")
expect(canRemoveServer({ key, stored })).toBe(true)
expect(canRemoveServer({ key, stored, provided: [{ type: "http", http: { url: key } }] })).toBe(false)
})
test("preserves existing local state while merging a canonical web bucket", () => {
expect(
migrateCanonicalLocalServerState(
{
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
projects: {
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local" },
})
test("project actions update schema-derived state and follow dynamic server scopes", () => {
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
server: ServerConnection.Key.make("https://remote.example"),
}
const projects = createServerProjects({
store,
setStore,
scope: () => ServerScope.fromServerKey(props.server, props.canonicalLocalServer),
})
projects.open("/remote")
projects.collapse("/remote")
projects.touch("/remote")
expect(projects.list()).toEqual([{ worktree: "/remote", expanded: false }])
expect(projects.last()).toBe("/remote")
props.canonicalLocalServer = props.server
expect(projects.list()).toEqual([])
projects.open("/local")
projects.close("/local")
expect(projects.recentlyClosed()).toEqual(["/local"])
projects.open("/local")
expect(projects.recentlyClosed()).toEqual([])
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
})
+20 -105
View File
@@ -1,18 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { pathKey } from "@/workspaces/path-key"
import { ServerScope } from "@/runtime/server/scope"
import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistence"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = {
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
@@ -38,65 +32,12 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateServerAuthState(value: unknown) {
if (!isRecord(value) || !Array.isArray(value.list)) return value
return {
...value,
list: value.list.map((server) => {
if (!isRecord(server)) return server
const http = isRecord(server.http) ? server.http : server
if (!("username" in http)) return server
const next = { ...http }
delete next.username
return http === server ? next : { ...server, http: next }
}),
}
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
export function createServerProjects(input: {
scope: () => ServerScope
store: Store<T>
setStore: SetStoreFunction<T>
store: Store<ServerState>
setStore: SetStoreFunction<ServerState>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const setStore = input.setStore
const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
@@ -162,22 +103,13 @@ export function createServerProjects<T extends ServerProjectState>(input: {
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
stored: ServerConnection.Http[]
}): Array<ServerConnection.Any> {
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: "http" in value
? value
: { type: "http", http: value }
for (const conn of input.stored) {
const key = ServerConnection.key(conn)
const existing = deduped.get(key)
@@ -196,28 +128,19 @@ export function resolveServerList(input: {
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
stored: StoredServer[]
stored: ServerConnection.Http[]
}) {
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
return input.stored.some((server) =>
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
)
return input.stored.some((server) => server.http.url === input.key)
}
export namespace ServerConnection {
type Base = { displayName?: string; label?: string }
export type HttpBase = {
url: string
password?: string
}
export type HttpBase = typeof ServerHttpBase.Type
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Http = typeof ServerHttp.Type
export type Sidecar = {
type: "sidecar"
@@ -259,8 +182,8 @@ export namespace ServerConnection {
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
export const Key = ServerKey
export type Key = typeof Key.Type
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
export const local = (conn?: Any) =>
@@ -280,19 +203,11 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
...Persist.global("server"),
sync: true,
previousKey: "server.v3",
migrate: (value) => migrateCanonicalLocalServerState(migrateServerAuthState(value), props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
hidden: {} as Record<string, boolean>,
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}),
serverState(() => props.canonicalLocalServer),
{ list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} },
)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
})
@@ -303,7 +218,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
const existing = store.list.findIndex((x) => x.http.url === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
@@ -314,7 +229,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => url(x) !== key)
const list = store.list.filter((x) => x.http.url !== key)
batch(() => {
setStore("list", list)
})
+6 -12
View File
@@ -12,6 +12,7 @@ import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -98,18 +99,11 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
})
function createGlobalModels() {
const [store, setStore, _, ready] = persisted(
Persist.global("model"),
createStore<{
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
recent: Array<{ providerID: string; modelID: string }>
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const [store, setStore, _, ready] = persisted(Persist.global("model"), ModelState, {
user: [],
recent: [],
variant: {},
})
const [recent] = createResource(
async () => {
const value = store.recent
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
expect(decode({ app })).toEqual({ app })
})
test.each([undefined, null, 42, "unknown", {}])("defaults invalid selection %p", (app) => {
expect(decode({ app })).toEqual({ app: "finder" })
})
test("defaults an absent selection", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,6 +5,8 @@ import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const OPEN_APPS = [
"vscode",
@@ -26,6 +28,10 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -163,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
const [prefs, setPrefs] = persisted(Persist.global("open.app"), OpenAppPreferences, { app: "finder" })
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
+1
View File
@@ -129,6 +129,7 @@ export function useSessionModel() {
layout: {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+12 -6
View File
@@ -5,18 +5,24 @@ import {
type SessionReviewExpandMode,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
createStore({
sidebarOpened: true,
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
expandMode: "collapse" as SessionReviewExpandMode,
}),
ReviewPanel,
{ sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" },
platform,
)
// The filter is transient by design: a persisted filter would silently hide
+3 -2
View File
@@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const view = session.layout.view
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
@@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
width: view().reviewPanel.width(),
available: available(),
split: splitReview(),
}),
@@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) {
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
+57 -26
View File
@@ -11,10 +11,8 @@ import {
on,
} from "solid-js"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
@@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) {
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
const sidePresence = createAnimatedPresence(
() => sideVisible() || undefined,
() => elements.side ?? null,
session.layout.tabKey,
)
const bottomTerminalPresence = createAnimatedPresence(
() => bottomTerminalVisible() || undefined,
() => elements.bottomTerminal ?? null,
session.layout.tabKey,
)
const sideMotion = createMemo<{
key?: string
region: boolean
terminal: boolean
animateRegion: boolean
animateTerminal: boolean
}>((previous) => {
const key = session.layout.tabKey()
const region = screen.side.region.open()
const terminal = sideTerminalVisible()
const sameTab = previous?.key === key
return {
key,
region,
terminal,
animateRegion: !!previous && sameTab && previous.region !== region,
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
}
})
const paneAnimating = () =>
sidePresence.animate() ||
sideMotion().animateRegion ||
sideMotion().animateTerminal ||
bottomTerminalPresence.animate()
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
@@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) {
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active(),
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
}}
data-slot="session-chat-panel"
style={{
@@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) {
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
session.layout.view().reviewPanel.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sideVisible()}
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
@@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-region"
classList={{
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.region.height() }}
>
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={screen.side.region.open()}
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
@@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) {
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !paneAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
@@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-terminal-region"
classList={{
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideTerminalVisible()}
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) {
fill
framed={false}
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalVisible()}
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
@@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
<TerminalPanel
stacked={isDesktop()}
present={store.bottomTerminalCached}
animate={bottomTerminalPresence.animate()}
/>
</div>
</Show>
</div>
+23 -1
View File
@@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { ServerConnection } from "@/runtime/server/registry"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
export const useSessionKey = () => {
const params = useParams()
@@ -19,12 +21,32 @@ export const useSessionKey = () => {
export const useSessionLayout = () => {
const layout = useLayout()
const tabs = useTabs()
const { params, sessionKey, workspaceKey } = useSessionKey()
const serverSDK = useServerSDK()
const currentTab = createMemo(() => {
if (!params.id) return
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
})
const panes = {
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
reviewOpened: () => tabs.pane(currentTab(), "review"),
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
}
return {
params,
sessionKey,
workspaceKey,
tabKey: createMemo(() => {
const tab = currentTab()
return tab && tabKey(tab)
}),
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
view: createMemo(() => layout.view(sessionKey, panes)),
}
}
@@ -2,11 +2,14 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/runtime/server/scope"
import { base64Encode } from "@opencode-ai/util/encode"
import { Persist } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
let migrateTerminalState: (value: unknown) => unknown
let decodeTerminalState: (value: unknown) => unknown
let roundTripTerminalState: (value: unknown) => unknown
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
@@ -15,16 +18,13 @@ beforeAll(async () => {
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
migrateTerminalState = mod.migrateTerminalState
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
roundTripTerminalState = (value) =>
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -61,10 +61,10 @@ describe("getWorkspaceTerminalCacheKey", () => {
})
})
describe("migrateTerminalState", () => {
describe("TerminalState", () => {
test("drops invalid terminals and restores a valid active terminal", () => {
expect(
migrateTerminalState({
decodeTerminalState({
active: "missing",
all: [
null,
@@ -85,7 +85,7 @@ describe("migrateTerminalState", () => {
test("keeps a valid active id", () => {
expect(
migrateTerminalState({
decodeTerminalState({
active: "two",
all: [
{ id: "one", title: "Terminal 1" },
@@ -100,4 +100,44 @@ describe("migrateTerminalState", () => {
],
})
})
test("defaults missing and malformed fields without dropping usable terminals", () => {
expect(decodeTerminalState({})).toEqual({ active: undefined, all: [] })
expect(decodeTerminalState({ active: 2, all: "invalid" })).toEqual({ active: undefined, all: [] })
expect(decodeTerminalState({ all: [null, {}, { id: "" }, { id: 2 }] })).toEqual({ active: undefined, all: [] })
expect(
decodeTerminalState({
all: [
{
id: "one",
title: "Terminal 3",
titleNumber: Infinity,
rows: "24",
cols: 80,
buffer: false,
cursor: NaN,
scrollY: 0,
},
{ id: "two", title: null, titleNumber: -1, buffer: "saved", cursor: 0 },
],
}),
).toEqual({
active: "one",
all: [
{ id: "one", title: "Terminal 3", titleNumber: 3, cols: 80, scrollY: 0 },
{ id: "two", title: "", titleNumber: 0, buffer: "saved", cursor: 0 },
],
})
})
test("round trips normalized terminal state", () => {
const value = {
active: "two",
all: [
{ id: "one", title: "Terminal 1", titleNumber: 1 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80, buffer: "output", cursor: 12, scrollY: 3 },
],
}
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+36 -77
View File
@@ -8,81 +8,51 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { defaultTitle, titleNumber } from "./title"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
export type LocalPTY = {
id: string
title: string
titleNumber: number
rows?: number
cols?: number
buffer?: string
scrollY?: number
cursor?: number
}
const PTY = Persistence.struct({
id: Schema.NonEmptyString,
title: Persistence.fallback(Schema.String, () => ""),
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
rows: Persistence.optional(Schema.Finite),
cols: Persistence.optional(Schema.Finite),
buffer: Persistence.optional(Schema.String),
scrollY: Persistence.optional(Schema.Finite),
cursor: Persistence.optional(Schema.Finite),
})
export type LocalPTY = typeof PTY.Type
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function text(value: unknown) {
return typeof value === "string" ? value : undefined
}
function num(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
function pty(value: unknown): LocalPTY | undefined {
if (!record(value)) return
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
})
const id = text(value.id)
if (!id) return
const title = text(value.title) ?? ""
const number = num(value.titleNumber)
const rows = num(value.rows)
const cols = num(value.cols)
const buffer = text(value.buffer)
const scrollY = num(value.scrollY)
const cursor = num(value.cursor)
return {
id,
title,
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
...(rows !== undefined ? { rows } : {}),
...(cols !== undefined ? { cols } : {}),
...(buffer !== undefined ? { buffer } : {}),
...(scrollY !== undefined ? { scrollY } : {}),
...(cursor !== undefined ? { cursor } : {}),
}
}
export function migrateTerminalState(value: unknown) {
if (!record(value)) return value
const seen = new Set<string>()
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
const next = pty(item)
if (!next || seen.has(next.id)) return []
seen.add(next.id)
return [next]
})
const active = text(value.active)
return {
active: active && seen.has(active) ? active : all[0]?.id,
all,
}
}
export const TerminalState = State.pipe(
Schema.decodeTo(Schema.toType(State), {
decode: SchemaGetter.transform((value) => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -131,18 +101,7 @@ function createWorkspaceTerminalSession(
) {
const location = { directory: sdk.directory }
const [store, setStore, _, ready] = persisted(
{
...terminalPersistTarget(scope, dir),
migrate: migrateTerminalState,
},
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
const [store, setStore, _, ready] = persisted(terminalPersistTarget(scope, dir), TerminalState, { all: [] })
const [ui, setUi] = createStore({
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
})
+4 -4
View File
@@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/session/terminal/terminal"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
@@ -45,9 +44,9 @@ export function TerminalPanel(
present?: boolean
contentHeight?: string
embedded?: boolean
animate?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
@@ -57,7 +56,7 @@ export function TerminalPanel(
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const height = createMemo(() => view().terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
@@ -238,10 +237,11 @@ export function TerminalPanel(
pane={pane()}
max={max()}
resizing={size.active()}
animate={props.animate}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
view().terminal.resize(next)
}}
onCollapse={close}
>
@@ -15,6 +15,7 @@ export function TerminalSurface(
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
@@ -27,7 +28,9 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
}),
)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
@@ -2,7 +2,8 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode-ai/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -14,6 +15,13 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
})
function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status
@@ -39,15 +47,12 @@ export function useUsageExceededDialogs() {
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
createStore({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
}),
)
const [goUpsellState, setGoUpsellState] = persisted(Persist.global("go-upsell"), GoUpsellState, {
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
})
onCleanup(
sdk().event.on("session.status", (evt) => {
+163 -9
View File
@@ -1,16 +1,32 @@
import { describe, expect, test } from "bun:test"
import { migrateSettings, monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
settingsPersistence,
defaultSettings,
monoDefault,
monoFontFamily,
sansDefault,
sansFontFamily,
terminalFontFamily,
} from "./model"
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"],
])("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
expect(migrateSettings(value)).toEqual({
...value,
general: { ...value.general, reasoningMode },
})
const settings = decode(value)
expect(settings.general.reasoningMode).toBe(reasoningMode)
expect(settings.general.showTerminal).toBe(true)
expect(settings.appearance.fontSize).toBe(16)
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
expect(value.general).not.toHaveProperty("reasoningMode")
})
@@ -19,13 +35,151 @@ describe("settings reasoning mode migration", () => {
(reasoningMode) => {
;[true, false].forEach((showReasoningSummaries) => {
const value = { general: { reasoningMode, showReasoningSummaries } }
expect(migrateSettings(value)).toBe(value)
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
})
},
)
test.each([undefined, null, {}, { general: {} }])("leaves missing legacy settings to the defaults: %j", (value) => {
expect(migrateSettings(value)).toBe(value)
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)
})
})
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 },
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(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
test("supplies the existing defaults for an empty document", () => {
expect(decode({})).toEqual({
general: {
autoSave: true,
releaseNotes: true,
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
keybinds: {},
permissions: { autoApprove: false },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
notifications: { agent: true, permissions: true, errors: false },
sounds: {
agentEnabled: true,
agent: "staplebops-01",
permissionsEnabled: true,
permissions: "staplebops-02",
errorsEnabled: true,
errors: "nope-03",
},
})
})
test("defaults invalid preferences locally while retaining valid siblings", () => {
const settings = decode({
general: {
showTerminal: true,
autoSave: false,
releaseNotes: undefined,
reasoningMode: 3,
followUpBehavior: "invalid",
},
appearance: { fontSize: "large", mono: "Custom Mono", tabLayout: "vertical" },
permissions: { autoApprove: true },
workspaces: { defaultDestination: "new", lastUsed: { good: "workspace", bad: true } },
keybinds: { good: "ctrl+k", bad: 3 },
notifications: { agent: false, permissions: "yes", errors: true },
sounds: { agent: "custom", agentEnabled: false, permissions: 3 },
})
expect(settings.general).toMatchObject({
showTerminal: true,
autoSave: false,
releaseNotes: true,
reasoningMode: "compact",
followUpBehavior: "steer",
})
expect(settings.appearance).toEqual({
fontSize: 14,
mono: "Custom Mono",
sans: "",
terminal: "",
tabLayout: "vertical",
})
expect(settings.permissions.autoApprove).toBe(true)
expect(settings.workspaces).toEqual({ defaultDestination: "new", lastUsed: { good: "workspace" } })
expect(settings.keybinds).toEqual({ good: "ctrl+k" })
expect(settings.notifications).toEqual({ agent: false, permissions: true, errors: true })
expect(settings.sounds).toMatchObject({ agent: "custom", agentEnabled: false, permissions: "staplebops-02" })
expect(decode(encode(settings))).toEqual(settings)
})
test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))(
"defaults malformed sections without losing other sections: %j",
(invalid) => {
const defaults = decode({})
expect(
decode({
general: invalid,
appearance: { fontSize: 18 },
keybinds: invalid,
permissions: invalid,
workspaces: invalid,
notifications: invalid,
sounds: invalid,
}),
).toEqual({
...defaults,
appearance: { ...defaults.appearance, fontSize: 18 },
})
},
)
test("does not silently repair invalid values during encoding", () => {
expect(() =>
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
).toThrow()
})
})
+109 -95
View File
@@ -1,68 +1,20 @@
import { createStore, reconcile } from "solid-js/store"
import { reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
export type TabLayout = "horizontal" | "vertical"
export interface NotificationSettings {
agent: boolean
permissions: boolean
errors: boolean
}
export interface SoundSettings {
agentEnabled: boolean
agent: string
permissionsEnabled: boolean
permissions: string
errorsEnabled: boolean
errors: string
}
export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showProjectIcon: boolean
showTerminal: boolean
reasoningMode: ReasoningMode
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
mobileDiffWrap: boolean
terminalPlacement: TerminalPlacement
followUpBehavior: FollowUpBehavior
}
appearance: {
fontSize: number
mono: string
sans: string
terminal: string
tabLayout: TabLayout
}
keybinds: Record<string, string>
permissions: {
autoApprove: boolean
}
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings
sounds: SoundSettings
}
export type Settings = typeof settingsSchema.Type
export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestination"]
export type WorkspaceLastUsed = Settings["workspaces"]["lastUsed"][string]
export type TerminalPlacement = Settings["general"]["terminalPlacement"]
export type FollowUpBehavior = Settings["general"]["followUpBehavior"]
export type TabLayout = Settings["appearance"]["tabLayout"]
export type NotificationSettings = Settings["notifications"]
export type SoundSettings = Settings["sounds"]
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
@@ -116,7 +68,99 @@ export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const defaultSettings: Settings = {
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
const generalSchema = Persistence.struct({
autoSave: Schema.Boolean,
releaseNotes: Schema.Boolean,
showFileTree: Schema.Boolean,
showNavigation: Schema.Boolean,
showSearch: Schema.Boolean,
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
reasoningMode: reasoningModeSchema,
shellToolPartsExpanded: Schema.Boolean,
editToolPartsExpanded: Schema.Boolean,
showCustomAgents: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
mobileDiffWrap: Schema.Boolean,
terminalPlacement: Schema.Literals(["side", "bottom"]),
followUpBehavior: Schema.Literals(["queue", "steer"]),
})
const appearanceSchema = Persistence.struct({
fontSize: Schema.Number,
mono: Schema.String,
sans: Schema.String,
terminal: Schema.String,
tabLayout: Schema.Literals(["horizontal", "vertical"]),
})
const permissionsSchema = Persistence.struct({
autoApprove: Schema.Boolean,
})
const workspacesSchema = Persistence.struct({
defaultDestination: Schema.Literals(["last-used", "local", "new"]),
lastUsed: Persistence.record(
Schema.Literals(["local", "workspace"]).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
})
const notificationsSchema = Persistence.struct({
agent: Schema.Boolean,
permissions: Schema.Boolean,
errors: Schema.Boolean,
})
const soundsSchema = Persistence.struct({
agentEnabled: Schema.Boolean,
agent: Schema.String,
permissionsEnabled: Schema.Boolean,
permissions: Schema.String,
errorsEnabled: Schema.Boolean,
errors: Schema.String,
})
export const settingsSchema = Persistence.struct({
general: generalSchema,
appearance: appearanceSchema,
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
permissions: permissionsSchema,
workspaces: workspacesSchema,
notifications: notificationsSchema,
sounds: soundsSchema,
})
export const settingsPersistence = Persistence.migrate(
settingsSchema,
Schema.Struct({
general: Persistence.optional(
Schema.Struct({
reasoningMode: Schema.optional(Schema.Unknown),
showReasoningSummaries: Persistence.optional(Schema.Boolean),
}),
),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
return value
return {
...value,
general: {
...value.general,
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
},
}
}),
encode: SchemaGetter.transform((value) => value),
}),
),
)
export const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
@@ -135,26 +179,11 @@ const defaultSettings: Settings = {
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: {
fontSize: 14,
mono: "",
sans: "",
terminal: "",
tabLayout: "horizontal",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
keybinds: {},
permissions: {
autoApprove: false,
},
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: {
agent: true,
permissions: true,
errors: false,
},
permissions: { autoApprove: false },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
notifications: { agent: true, permissions: true, errors: false },
sounds: {
agentEnabled: true,
agent: "staplebops-01",
@@ -169,26 +198,11 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
export function migrateSettings(value: unknown) {
if (!value || typeof value !== "object" || !("general" in value)) return value
const general = value.general
if (!general || typeof general !== "object") return value
if ("reasoningMode" in general && general.reasoningMode !== undefined) return value
if (!("showReasoningSummaries" in general) || typeof general.showReasoningSummaries !== "boolean") return value
return {
...value,
general: { ...general, reasoningMode: general.showReasoningSummaries ? "full" : "compact" },
}
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
gate: false,
init: () => {
const [store, setStore, , ready] = persisted(
{ key: "settings.v3", migrate: migrateSettings },
createStore<Settings>(defaultSettings),
)
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
+8 -2
View File
@@ -5,7 +5,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -20,13 +21,18 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component = () => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
ModelProvidersSchema,
{ collapsed: {} },
)
const list = useFilteredList<ModelItem>({
@@ -1,12 +1,23 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import {
activeCommandRegistrations,
addCommandRegistration,
commandPaletteOptions,
CommandCatalog,
resolveKeybindOption,
type CommandOption,
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = Schema.decodeUnknownSync(CommandCatalog)
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
expect(decode({})).toEqual({})
expect(() => decode({ open: { title: 1 } })).toThrow()
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
{ id: "session.undo", title: "Undo" },
+14 -13
View File
@@ -2,6 +2,8 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
@@ -100,14 +102,17 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
title: string
description?: string
category?: string
keybind?: KeybindConfig
slash?: string
hidden?: boolean
}
export const CommandCatalogItem = Persistence.struct({
title: Schema.String,
description: Schema.optional(Schema.String),
category: Schema.optional(Schema.String),
keybind: Schema.optional(Schema.String),
slash: Schema.optional(Schema.String),
hidden: Schema.optional(Schema.Boolean),
})
export type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
key?: string
@@ -268,11 +273,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
createStore<CommandCatalog>({}),
)
const [catalog, setCatalog, _, catalogReady] = persisted(Persist.global("command.catalog.v1"), CommandCatalog, {})
const bind = (id: string, def: KeybindConfig | undefined) => {
const custom = settings.keybinds.get(actionId(id))
+90
View File
@@ -0,0 +1,90 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
+47
View File
@@ -0,0 +1,47 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -0,0 +1,34 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+20 -22
View File
@@ -1,7 +1,8 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import "./status/status-drawer.css"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -13,32 +14,29 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<Drawer
<MobileDrawer
open={props.open}
onOpenChange={props.onOpenChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
returnFocus={props.returnFocus}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</Drawer.Close>
</MobileDrawerClose>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
)
}
@@ -1,7 +1,24 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { openNotificationSession } from "./notification"
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
import { Persistence } from "@/runtime/persistence/schema"
test("notification persistence validates and salvages individual notifications", () => {
const valid: Notification[] = [
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
]
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
const store = decode({
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
})
expect(store.list).toEqual(valid)
expect(decode({})).toEqual({ list: [] })
expect(decode({ list: {} })).toEqual({ list: [] })
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
@@ -1,14 +1,16 @@
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "@/runtime/server/client"
import type { Data } from "@opencode-ai/client/solid"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
@@ -17,24 +19,19 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
type NotificationBase = {
directory?: string
session?: string
metadata?: unknown
time: number
viewed: boolean
const NotificationBase = {
directory: Schema.optional(Schema.String),
session: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Unknown),
time: Schema.Finite,
viewed: Schema.Boolean,
}
type TurnCompleteNotification = NotificationBase & {
type: "turn-complete"
}
type ErrorNotification = NotificationBase & {
type: "error"
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
export const Notification = Schema.Union([
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
type NotificationIndex = {
session: {
@@ -53,11 +50,7 @@ type NotificationIndex = {
type NotificationTabs = Pick<ReturnType<typeof useTabs>, "addSessionTab" | "rememberSessionRoute" | "select">
export function openNotificationSession(
tabs: NotificationTabs,
server: ServerConnection.Key,
sessionID: string,
) {
export function openNotificationSession(tabs: NotificationTabs, server: ServerConnection.Key, sessionID: string) {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
if (tab.type !== "session") return
tabs.rememberSessionRoute(tab, sessionID)
@@ -130,9 +123,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(input.sdk.scope, "notification"),
createStore({
list: [] as Notification[],
}),
NotificationStore,
{ list: [] },
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
@@ -230,10 +222,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
if (!session) return
if (session.parentID) return
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.agentEnabled()
) {
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
}
@@ -253,20 +242,12 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
})
}
const handleSessionError = (
sessionID: string,
error: ErrorNotification["error"],
eventID: string,
time: number,
) => {
const handleSessionError = (sessionID: string, error: SessionError.Error, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.errorsEnabled()
) {
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
}
@@ -1,7 +1,105 @@
import { describe, expect, test } from "bun:test"
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 { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
const defaults = decode({})
expect(defaults).toEqual({
sidebar: { opened: false, width: 344, workspaces: {}, workspacesDefault: false },
terminal: { height: 280, opened: false },
review: { diffStyle: "split", panelOpened: false },
fileTree: { opened: false, width: 200, tab: "changes" },
session: { width: 600 },
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: { server: ServerConnection.Key.make("local") } },
})
expect(
decode({
sidebar: { width: "bad" },
terminal: null,
session: { width: undefined },
review: { diffStyle: "bad" },
}),
).toEqual(defaults)
})
test("migrates old sidebar and panel settings and writes current fields", () => {
const value = decode({ sidebar: { workspaces: true }, review: {}, fileTree: { opened: true, width: 260 } })
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
test("preserves current panel preferences", () => {
const value = decode({
review: { diffStyle: "unified", panelOpened: false },
fileTree: { opened: true, width: 260, tab: "all" },
})
expect(value.review).toEqual({ diffStyle: "unified", panelOpened: false })
expect(value.fileTree).toEqual({ opened: true, width: 260, tab: "all" })
})
test("distinguishes an invalid panel field from an invalid review section", () => {
const fileTree = { opened: true, tab: "all" }
expect(decode({ review: { panelOpened: "bad" }, fileTree }).review.panelOpened).toBe(true)
expect(decode({ review: null, fileTree }).review.panelOpened).toBe(false)
})
test("preserves whole-record and whole-entry recovery for strict fields", () => {
const key = "local\u0000L3Byb2plY3Q/session"
const scroll = { good: { x: 1, y: 2 }, bad: { x: "bad", y: 3 } }
expect(
decode({
sidebar: { workspaces: { good: true, bad: "bad" } },
sessionView: { [key]: { scroll, reviewMode: "git" } },
}),
).toMatchObject({
sidebar: { workspaces: {} },
sessionView: { [key]: { scroll: {}, reviewMode: "git" } },
})
expect(
decode({ sessionView: { [key]: { scroll: { good: { x: 1, y: 2 } }, reviewMode: "bad" } } }).sessionView,
).toEqual({ [key]: { scroll: {} } })
})
test("keeps scoped state and salvages valid tab entries", () => {
const key = "local\u0000L3Byb2plY3Q/session"
const value = decode({
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } },
sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } },
})
expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } })
expect(value.sessionView).toEqual({ [key]: { scroll: {}, reviewOpen: ["a", "b"] } })
})
})
describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => {
const calls: string[] = []
+166 -145
View File
@@ -1,4 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -7,6 +8,8 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import type { Project } from "@/runtime/server/types"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { TabStorage } from "@/shell/tabs/schema"
import { decode64 } from "@/runtime/persistence/base64"
import { same } from "@/runtime/persistence/equality"
import { createScrollPersistence, type SessionScroll } from "./scroll"
@@ -44,21 +47,22 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type HomeProjectSelection = typeof layoutSchema.Type.home.selection
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewDiffStyle = typeof layoutSchema.Type.review.diffStyle
export type ReviewChangeMode = NonNullable<(typeof layoutSchema.Type.sessionView)[string]["reviewMode"]>
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
terminalHeight: Accessor<number | undefined>
setTerminalHeight(height: number): void
reviewOpened: Accessor<boolean>
setReviewOpened(opened: boolean): void
sessionWidth: Accessor<number | undefined>
setSessionWidth(width: number): void
}
export type LayoutRoute =
| { type: "home" }
@@ -123,6 +127,114 @@ export const useCurrentRoute = () => {
return createMemo(() => currentRoute(location.pathname, location.search))
}
const sessionTabsSchema = Persistence.struct({
all: Persistence.array(Schema.String),
active: Persistence.optional(Schema.String),
})
const sessionViewSchema = Persistence.struct({
scroll: Persistence.record(Schema.Struct({ x: Schema.Finite, y: Schema.Finite })),
reviewOpen: Schema.optional(Persistence.array(Schema.String)),
reviewMode: Schema.optional(Schema.Literals(["git", "branch", "turn"])),
reviewFile: Schema.optional(Schema.String),
pendingMessage: Schema.optional(Schema.String),
pendingMessageAt: Schema.optional(Schema.Finite),
})
export const layoutSchema = Persistence.struct({
sidebar: Persistence.struct({
opened: Schema.Boolean,
width: Schema.Finite,
workspaces: Persistence.record(Schema.Boolean),
workspacesDefault: Schema.Boolean,
}),
terminal: Persistence.struct({ height: Schema.Finite, opened: Schema.Boolean }),
review: Persistence.struct({
diffStyle: Schema.Literals(["unified", "split"]),
panelOpened: Schema.Boolean,
}),
fileTree: Persistence.struct({
opened: Schema.Boolean,
width: Schema.Finite,
tab: Schema.Literals(["changes", "all"]),
}),
session: Persistence.struct({ width: Schema.Finite }),
mobileSidebar: Persistence.struct({ opened: Schema.Boolean }),
sessionTabs: Persistence.record(Persistence.fallback(sessionTabsSchema, () => ({ all: [] }))),
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: TabStorage.ServerKey,
directory: Schema.optional(Schema.String),
}),
}),
})
export const layoutPersistence = Persistence.migrate(
layoutSchema,
Schema.Struct({
sidebar: Persistence.optional(
Schema.Struct({
workspaces: Persistence.optional(Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Schema.Boolean)])),
workspacesDefault: Persistence.optional(Schema.Boolean),
}),
),
review: Persistence.optional(Schema.Struct({ panelOpened: Persistence.optional(Schema.Boolean) })),
fileTree: Persistence.optional(
Schema.Struct({
opened: Persistence.optional(Schema.Boolean),
width: Persistence.optional(Schema.Finite),
tab: Persistence.optional(Schema.Literals(["changes", "all"])),
}),
),
sessionTabs: layoutSchema.fields.sessionTabs,
sessionView: layoutSchema.fields.sessionView,
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
...value,
sidebar:
typeof value.sidebar?.workspaces === "boolean"
? { ...value.sidebar, workspaces: {}, workspacesDefault: value.sidebar.workspaces }
: value.sidebar,
// Only an existing review section inherits the old file-tree panel flag.
review: value.review
? { ...value.review, panelOpened: value.review.panelOpened ?? value.fileTree?.opened }
: value.review,
fileTree:
value.fileTree && !value.fileTree.tab
? {
...value.fileTree,
opened: true,
width: value.fileTree.width === 260 ? DEFAULT_FILE_TREE_WIDTH : value.fileTree.width,
tab: "changes" as const,
}
: value.fileTree,
sessionTabs: Object.fromEntries(
Object.entries(value.sessionTabs)
.filter(([key]) => SessionStateKey.is(key))
.map(([key, tabs]) => [key, normalizeStoredSessionTabs(key, tabs)]),
),
sessionView: Object.fromEntries(Object.entries(value.sessionView).filter(([key]) => SessionStateKey.is(key))),
})),
encode: SchemaGetter.transform((value) => value),
}),
),
)
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
review: { diffStyle: "split", panelOpened: DEFAULT_REVIEW_PANEL_OPENED },
fileTree: { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" },
session: { width: DEFAULT_SESSION_WIDTH },
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: { server } },
}
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
@@ -130,137 +242,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const servers = useServers()
const platform = usePlatform()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const currentSessionState = (value: unknown) => {
if (!isRecord(value)) return value
const entries = Object.entries(value)
if (entries.every(([key]) => SessionStateKey.is(key))) return value
return Object.fromEntries(entries.filter(([key]) => SessionStateKey.is(key)))
}
const migrate = (value: unknown) => {
if (!isRecord(value)) return value
const sidebar = value.sidebar
const migratedSidebar = (() => {
if (!isRecord(sidebar)) return sidebar
if (typeof sidebar.workspaces !== "boolean") return sidebar
return {
...sidebar,
workspaces: {},
workspacesDefault: sidebar.workspaces,
}
})()
const review = value.review
const fileTree = value.fileTree
const migratedFileTree = (() => {
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
return {
...fileTree,
opened: true,
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
tab: "changes",
}
})()
const migratedReview = (() => {
if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review
const opened =
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
return {
...review,
panelOpened: opened,
}
})()
const sessionTabs = currentSessionState(value.sessionTabs)
const sessionView = currentSessionState(value.sessionView)
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
let changed = false
const next = Object.fromEntries(
Object.entries(sessionTabs).map(([key, tabs]) => {
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
const current = {
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
active: typeof tabs.active === "string" ? tabs.active : undefined,
}
const normalized = normalizeStoredSessionTabs(key, current)
if (current.all.length !== tabs.all.length) changed = true
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
return [key, normalized]
}),
)
if (!changed) return sessionTabs
return next
})()
if (
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === value.sessionTabs &&
sessionView === value.sessionView
) {
return value
}
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
sessionView,
}
}
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6", migrate },
createStore({
sidebar: {
opened: false,
width: DEFAULT_SIDEBAR_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
terminal: {
height: DEFAULT_TERMINAL_HEIGHT,
opened: false,
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: DEFAULT_REVIEW_PANEL_OPENED,
},
fileTree: {
opened: false,
width: DEFAULT_FILE_TREE_WIDTH,
tab: "changes" as "changes" | "all",
},
session: {
width: DEFAULT_SESSION_WIDTH,
},
mobileSidebar: {
opened: false,
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
home: {
selection: { server: ServerConnection.key(servers.list[0]) } as HomeProjectSelection,
},
}),
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(ServerConnection.key(servers.list[0])),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
@@ -508,7 +493,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return message
},
},
view(sessionKey: string | Accessor<string>) {
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
@@ -519,11 +504,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
const terminalHeight = createMemo(() =>
panes
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
)
const reviewPanelOpened =
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const sessionWidth = createMemo(() =>
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
if (panes) {
panes.setTerminalOpened(next)
return
}
const current = store.terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
@@ -537,6 +535,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
if (panes) {
batch(() => {
panes.setReviewOpened(next)
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const current = store.review
if (!current) {
batch(() => {
@@ -566,6 +571,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
opened: terminalOpened,
height: terminalHeight,
resize(height: number) {
if (panes) {
panes.setTerminalHeight(height)
return
}
setStore("terminal", "height", height)
},
open() {
setTerminalOpened(true)
},
@@ -579,6 +592,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
reviewPanel: {
opened: reviewPanelOpened,
source: reviewPanelSource,
width: sessionWidth,
resize(width: number) {
if (panes) {
panes.setSessionWidth(width)
return
}
setStore("session", "width", width)
},
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
@@ -1,109 +1,3 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -113,33 +7,3 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+2 -4
View File
@@ -1,9 +1,7 @@
import type { SessionTab, Tab } from "./tabs"
import type { TabStorage } from "./schema"
export type ClosedTab = {
tab: SessionTab
index: number
}
export type ClosedTab = typeof TabStorage.ClosedTab.Type
const CLOSED_TAB_LIMIT = 25
-50
View File
@@ -1,50 +0,0 @@
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown): Tab[] {
if (!Array.isArray(value)) return []
return value.flatMap<Tab>((tab) => {
if (!tab || typeof tab !== "object") return []
if (!("server" in tab) || typeof tab.server !== "string") return []
const server = tab.server as ServerConnection.Key
if (
tab.type === "session" &&
typeof tab.sessionId === "string" &&
(tab.routeSessionId === undefined || typeof tab.routeSessionId === "string") &&
(tab.routeParentId === undefined || typeof tab.routeParentId === "string")
) {
return [
{
type: tab.type,
server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? {
routeSessionId: tab.routeSessionId,
...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}),
}
: {}),
},
]
}
if (
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string") &&
(tab.branch === undefined || typeof tab.branch === "string")
) {
return [
{
type: tab.type,
server,
draftID: tab.draftID,
directory: tab.directory,
worktree: tab.worktree,
branch: tab.branch,
},
]
}
return []
})
}
+62
View File
@@ -0,0 +1,62 @@
export * as TabStorage from "./schema"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const Session = Persistence.struct({
type: Schema.Literal("session"),
server: ServerKey,
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
})
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
server: ServerKey,
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
})
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
}),
)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.UndefinedOr(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
+55 -9
View File
@@ -2,11 +2,14 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -15,27 +18,66 @@ function sessionTab(sessionId: string): SessionTab {
describe("tab migration", () => {
test("drops null and malformed persisted tabs", () => {
expect(
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
).toEqual([sessionTab("a")])
})
test("drops persisted tabs without a server", () => {
expect(migrateTabs([{ type: "session", sessionId: "a" }])).toEqual([])
expect(decodeTabs([{ type: "session", sessionId: "a" }])).toEqual([])
})
test("replaces invalid top-level persisted data", () => {
expect(migrateTabs(null)).toEqual([])
expect(migrateTabs({})).toEqual([])
expect(decodeTabs(null)).toEqual([])
expect(decodeTabs({})).toEqual([])
})
test("preserves the active child route", () => {
expect(migrateTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
expect(decodeTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" },
])
})
test("drops an invalid child route", () => {
expect(migrateTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([])
expect(decodeTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([sessionTab("parent")])
expect(decodeTabs([{ ...sessionTab("parent"), routeSessionId: "child", routeParentId: 1 }])).toEqual([
{ ...sessionTab("parent"), routeSessionId: "child" },
])
})
test("encodes only canonical tabs and preserves drafts", () => {
const draft: Tab = { type: "draft", server, draftID: "draft", directory: "/project", branch: "main" }
const tabs = decodeTabs([
{ ...sessionTab("root"), routeSessionId: "root", routeParentId: "stale", legacy: true },
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
null,
]),
).toEqual([{ tab: sessionTab("a"), index: 1 }])
})
test("validates auxiliary tab state", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
).toEqual({ key: undefined })
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
})
})
@@ -48,8 +90,12 @@ test("session tab identity stays rooted while its href follows the child route",
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
const tab = { ...sessionTab("root"), routeSessionId: "child" }
const tabs = [tab]
expect(findSessionTab(tabs, server, "root")).toBe(tab)
expect(findSessionTab(tabs, server, "child")).toBe(tab)
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+66 -42
View File
@@ -13,27 +13,12 @@ import { sessionHref } from "@/shell/routes/session"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { createDraftComposerState, type PromptModel } from "@/composer/state"
import { migrateTabs } from "./migration"
import { TabStorage } from "./schema"
import { useCurrentRoute } from "@/shell/state/layout"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
routeSessionId?: string
routeParentId?: string
}
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
branch?: string
}
export type Tab = SessionTab | DraftTab
export type SessionTab = typeof TabStorage.Session.Type
export type DraftTab = typeof TabStorage.Draft.Type
export type Tab = typeof TabStorage.Tab.Type
export type PendingSession = {
draft: DraftTab
@@ -41,14 +26,10 @@ export type PendingSession = {
selection: ComposerSelection
}
export type TabInfo = {
title?: string
directory?: string
}
export type TabInfo = typeof TabStorage.Info.Type
type RecentTab = {
key?: string
}
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
@@ -62,8 +43,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.find(
(tab) =>
tab.type === "session" &&
tab.server === server &&
@@ -71,25 +52,23 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s
)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return !!findSessionTab(tabs, server, sessionID)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const servers = useServers()
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: migrateTabs,
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo, , infoReady] = persisted(
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [store, setStore, _, ready] = persisted(Persist.window("tabs"), TabStorage.Tabs, [])
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), TabStorage.Recent, {
key: undefined,
})
const [info, setInfo, , infoReady] = persisted(Persist.window("tabs.info"), TabStorage.Infos, {})
const [panes, setPanes, , panesReady] = persisted(Persist.window("tabs.panes"), TabStorage.Panes, {})
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), TabStorage.Closed, [])
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
const params = useParams()
@@ -141,6 +120,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
}
const removePanes = (key: string) => {
if (!panes[key]) return
setPanes(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -153,6 +141,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
removePanes(key)
}
}
setStore(() => next)
@@ -162,6 +151,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
if (!panesReady()) return
for (const key of Object.keys(panes)) {
if (!keys.has(key)) removePanes(key)
}
})
createEffect(() => {
@@ -198,6 +191,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
removePanes(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -491,8 +485,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
pane(tab: Tab | undefined, pane: TabPane) {
if (!tab) return false
return panes[tabKey(tab)]?.[pane] ?? false
},
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[pane] === opened) return
if (!current) {
setPanes(key, { [pane]: opened })
return
}
setPanes(key, pane, opened)
},
paneSize(tab: Tab | undefined, size: TabPaneSize) {
if (!tab) return
return panes[tabKey(tab)]?.[size]
},
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[size] === value) return
if (!current) {
setPanes(key, { [size]: value })
return
}
setPanes(key, size, value)
},
}
return { ...actions, store, info, ready, infoReady, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
},
})
+2 -82
View File
@@ -14,63 +14,13 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
margin-block-start: 8px;
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -79,36 +29,6 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+12 -22
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import Drawer from "@corvu/drawer"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<Drawer
<MobileDrawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,11 +423,9 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<Drawer.Trigger
<MobileDrawerTrigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -467,15 +465,11 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -493,7 +487,6 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -504,10 +497,7 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<button
type="button"
data-action="mobile-tabs-home"
@@ -546,9 +536,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
</div>
</MobileDrawerContent>
</MobileDrawer>
}
>
<Show
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Persistence } from "@/runtime/persistence/schema"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
expect(decode({})).toEqual({ version: undefined })
expect(decode({ version: null })).toEqual({ version: undefined })
const value = decode({ version: "1.2.3", legacy: true })
expect(value).toEqual({ version: "1.2.3" })
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
})
@@ -1,17 +1,19 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/runtime/platform/platform"
import { useSettings } from "@/settings/model"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { DialogReleaseNotes, type Highlight } from "@/shell/updates/release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
type Store = {
version?: string
}
export const HighlightsStore = Persistence.struct({
version: Schema.UndefinedOr(Schema.String),
})
type ParsedRelease = {
tag?: string
@@ -144,7 +146,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [store, setStore, _, ready] = persisted("highlights.v1", HighlightsStore, { version: undefined })
const [range, setRange] = createStore({
from: undefined as string | undefined,
+16 -12
View File
@@ -1,18 +1,22 @@
import type { FileContent } from "@/runtime/server/types"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
export type FileSelection = typeof FileSelection.Type
export type SelectedLineRange = {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
export type SelectedLineRange = typeof SelectedLineRange.Type
export type FileViewState = {
scrollTop?: number
+17 -10
View File
@@ -1,14 +1,26 @@
import { createEffect, createRoot } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { produce } from "solid-js/store"
import { Schema } from "effect"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types"
import { SelectedLineRange } from "./types"
import type { ServerScope } from "@/runtime/server/scope"
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const FileViewSchema = Persistence.struct({
scrollTop: Persistence.optional(Schema.Finite),
scrollLeft: Persistence.optional(Schema.Finite),
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
})
export const FileViewsSchema = Schema.Struct({
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
})
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return { ...range }
@@ -35,14 +47,9 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
}
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
const [view, setView, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "file-view"),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const [view, setView, _, ready] = persisted(Persist.serverScoped(scope, dir, id, "file-view"), FileViewsSchema, {
file: {},
})
const meta = { pruned: false }
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
import { batch, createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
@@ -47,3 +47,22 @@ test("animates the first appearance when initially hidden", () => {
dispose()
})
})
test("does not animate visibility changes across identities", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("a")
const [value, setValue] = createSignal<string | undefined>("visible")
const presence = createAnimatedPresence(value, () => null, identity)
expect(presence.animate()).toBe(false)
batch(() => {
setIdentity("b")
setValue(undefined)
})
expect(presence.animate()).toBe(false)
setValue("visible")
expect(presence.animate()).toBe(true)
dispose()
})
})
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { persisted } from "@/runtime/persistence/storage"
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
const key = `consumer-model-selection-${crypto.randomUUID()}`
localStorage.setItem(
key,
JSON.stringify({ pick: { session1: { agent: "plan" }, __workspace__: { agent: "build" } } }),
)
createRoot((dispose) => {
try {
const [state, setState] = persisted(
key,
ModelSelectionSchema,
{ session: {} },
{
platform: "web",
openExternal: () => {},
restart: async () => {},
notify: async () => {},
},
)
expect(state.session.session1?.agent).toBe("plan")
setState("session", "session1", { agent: "build", variant: null })
expect(state.session.session1?.agent).toBe("build")
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
session: { session1: { agent: "build", variant: null } },
})
} finally {
dispose()
localStorage.removeItem(key)
}
})
})
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
@@ -34,12 +34,96 @@ const platform: Platform = {
}
describe("prompt persistence", () => {
test.each([null, "null", '"invalid"', "not json"])(
"keeps dynamic initial input with unavailable stored state: %s",
async (raw) => {
const store = createDraftStore({
get: async () => raw,
set: async () => undefined,
remove: async () => undefined,
putBlob: async () => "unused",
getBlob: async () => null,
})
const model = { providerID: "provider", modelID: "model", variant: "high" }
const root = createRoot((dispose) => ({
dispose,
session: createComposerState(
ServerScope.local,
{ draftID: `draft-initial-${raw}` },
{ prompt: "initial prompt", model },
{ ...platform, draftStore: store },
),
}))
await root.session.ready.promise
expect(root.session.current()).toEqual([{ type: "text", content: "initial prompt", start: 0, end: 14 }])
expect(root.session.cursor()).toBe(14)
expect(root.session.model.current()).toEqual(model)
root.dispose()
},
)
test("decodes hydrated images and writes canonical blob references through draft storage", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async (blob) => {
blobs.set("composer-image", blob)
return "composer-image"
},
getBlob: async (id) => blobs.get(id) ?? null,
})
const target = Persist.draft("draft-schema-image", "prompt")
const key = `${target.storage}:${target.key}`
await store.setItem(
key,
JSON.stringify({
prompt: [
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
dataUrl: "data:image/png;base64,YQ==",
},
],
}),
)
const root = createRoot((dispose) => ({
dispose,
session: createComposerState(ServerScope.local, { draftID: "draft-schema-image" }, undefined, {
...platform,
draftStore: store,
}),
}))
await root.session.ready.promise
expect(root.session.current()).toEqual([
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
},
])
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
await Bun.sleep(0)
expect(documents.get(key)).toContain("hello")
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
expect(documents.get(key)).not.toContain("dataUrl")
expect(documents.get(key)).not.toContain("blob:")
root.dispose()
})
test("relocates a previous key into canonical storage", () => {
localStorage.setItem("server.v3", JSON.stringify({ list: ["https://example.com"] }))
const [state] = persisted(
{ ...Persist.global("server"), previousKey: "server.v3" },
createStore({ list: [] as string[] }),
Schema.Struct({ list: Schema.mutable(Schema.Array(Schema.String)) }),
{ list: [] },
platform,
)
@@ -66,3 +66,41 @@ test("enables sidebar motion only after custom width hydration", async () => {
})
})
})
test("recovers malformed preferences independently and keeps the filter transient", async () => {
const root = createPanel()
root.state.setFilter("transient")
read?.(JSON.stringify({ sidebarOpened: false, sidebarWidth: "wide", expandMode: "invalid", filter: "stored" }))
await root.ready
expect(root.state.sidebarOpened()).toBeFalse()
expect(root.state.sidebarWidth()).toBe(240)
expect(root.state.expandMode()).toBe("collapse")
expect(root.state.filter()).toBe("transient")
root.dispose()
})
test.each([0, 199, 481, null])("rejects invalid persisted sidebar width %p", async (sidebarWidth) => {
const root = createPanel()
read?.(JSON.stringify({ sidebarWidth, expandMode: "expand" }))
await root.ready
expect(root.state.sidebarWidth()).toBe(240)
expect(root.state.sidebarOpened()).toBeTrue()
expect(root.state.expandMode()).toBe("expand")
root.state.resizeSidebar(1000)
expect(root.state.sidebarWidth()).toBe(480)
root.state.resizeSidebar(0)
expect(root.state.sidebarWidth()).toBe(200)
root.dispose()
})
function createPanel() {
return createRoot((dispose) => {
const state = createReviewPanelState(platform)
const ready = new Promise<void>((resolve) => {
createEffect(() => {
if (state.sidebarTransition()) resolve()
})
})
return { dispose, state, ready }
})
}
@@ -0,0 +1,166 @@
import { describe, expect, test } from "bun:test"
import { Schema, SchemaGetter } from "effect"
import { createComputed, createRoot } from "solid-js"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const Current = Schema.Struct({
enabled: Schema.Boolean,
label: Schema.String,
})
const initial = { enabled: true, label: "default" }
const Stored = Persistence.migrate(
Current,
Schema.Struct({ oldLabel: Schema.optional(Schema.String), label: Schema.optional(Schema.String) }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) =>
value.oldLabel === undefined ? value : { ...value, label: value.oldLabel },
),
encode: SchemaGetter.passthrough(),
}),
),
)
const web: Platform = {
platform: "web",
openExternal: () => undefined,
restart: async () => undefined,
notify: async () => undefined,
}
function desktop() {
const values = new Map<string, string>()
const platform: Platform = {
...web,
platform: "desktop",
windowID: "schema-test",
openDirectoryPickerDialog: async () => null,
storage: (name) => ({
getItem: async (key) => values.get(`${name}:${key}`) ?? null,
setItem: async (key, value) => void values.set(`${name}:${key}`, value),
removeItem: async (key) => void values.delete(`${name}:${key}`),
}),
}
return { values, platform }
}
describe("schema-backed persistence", () => {
test("migrates sync storage and writes only the current representation", () => {
const target = Persist.global("schema-sync")
const key = `${target.storage}:${target.key}`
localStorage.setItem(key, JSON.stringify({ oldLabel: "saved" }))
createRoot((dispose) => {
const [state, setState, , ready] = persisted(target, Stored, initial, web)
expect(ready.promise).toBeUndefined()
expect(state).toEqual({ enabled: true, label: "saved" })
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
setState("enabled", false)
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
dispose()
})
})
test("recovers invalid fields and strips fields outside the schema", () => {
const target = Persist.global("schema-invalid-field")
localStorage.setItem(
`${target.storage}:${target.key}`,
JSON.stringify({ enabled: "false", label: "kept", extra: 1 }),
)
createRoot((dispose) => {
const [state] = persisted(target, Stored, initial, web)
expect(state).toEqual({ enabled: true, label: "kept" })
dispose()
})
})
test("malformed JSON falls back to a typed initial state", () => {
const target = Persist.global("schema-invalid-json")
localStorage.setItem(`${target.storage}:${target.key}`, '{"label":"\\x"}')
createRoot((dispose) => {
const [state] = persisted(target, Stored, { enabled: false, label: "initial" }, web)
expect(state).toEqual({ enabled: false, label: "initial" })
expect(localStorage.getItem(`${target.storage}:${target.key}`)).toBeNull()
dispose()
})
})
test("relocates and canonicalizes desktop state before becoming ready", async () => {
const storage = desktop()
storage.values.set("undefined:old-schema", JSON.stringify({ oldLabel: "desktop" }))
const root = createRoot((dispose) => ({
dispose,
state: persisted(
{ ...Persist.global("schema-desktop"), previousKey: "old-schema" },
Stored,
initial,
storage.platform,
),
}))
try {
expect(root.state[3]()).toBe(false)
await root.state[3].promise
expect(root.state[0]).toEqual({ enabled: true, label: "desktop" })
expect(storage.values.has("undefined:old-schema")).toBe(false)
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
enabled: true,
label: "desktop",
})
root.state[1]("label", "changed")
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
enabled: true,
label: "changed",
})
} finally {
root.dispose()
}
})
test("a late desktop read does not overwrite an edit made while loading", async () => {
const pending = Promise.withResolvers<string | null>()
const storage = desktop()
storage.platform.storage = () => ({
getItem: () => pending.promise,
setItem: async () => undefined,
removeItem: async () => undefined,
})
const root = createRoot((dispose) => ({
dispose,
state: persisted(Persist.global("schema-late"), Stored, initial, storage.platform),
}))
try {
root.state[1]("label", "new edit")
pending.resolve(JSON.stringify({ oldLabel: "old state" }))
await root.state[3].promise
expect(root.state[0].label).toBe("new edit")
} finally {
root.dispose()
}
})
test("cross-window updates use the same migration and validation boundary", async () => {
const target = { ...Persist.global("schema-sync-channel"), sync: true }
const channel = new BroadcastChannel(`opencode.persist:${target.storage}:${target.key}`)
const received = Promise.withResolvers<void>()
const values: unknown[] = []
const root = createRoot((dispose) => {
const [state] = persisted(target, Stored, initial, web)
createComputed(() => {
values.push({ enabled: state.enabled, label: state.label })
if (state.label === "from another window") received.resolve()
})
return { dispose, state }
})
try {
channel.postMessage({ key: target.key, newValue: JSON.stringify({ enabled: "false", label: "recovered" }) })
channel.postMessage({ key: target.key, newValue: JSON.stringify({ oldLabel: "from another window" }) })
await received.promise
expect(root.state).toEqual({ enabled: true, label: "from another window" })
expect(values).toContainEqual({ enabled: true, label: "recovered" })
expect(values).toContainEqual({ enabled: true, label: "from another window" })
} finally {
channel.close()
root.dispose()
}
})
})
+15
View File
@@ -1,6 +1,7 @@
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
import { Updater } from "../services/updater"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
@@ -56,6 +57,20 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
Argument.optional,
),
method: Flag.choice("method", Updater.methods).pipe(
Flag.withAlias("m"),
Flag.withDescription("Installation method to use"),
Flag.optional,
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -54,7 +54,7 @@ export function format(
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
? [{ target: plugin.source.target, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
@@ -73,7 +73,7 @@ export function format(
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -0,0 +1,38 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)

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