mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461c42f757 | ||
|
|
322bf226ba | ||
|
|
66ed350fea | ||
|
|
e65e1ecf59 | ||
|
|
d40b64a6bd | ||
|
|
71779ae5de | ||
|
|
4a3e25beab | ||
|
|
23fde448ec | ||
|
|
1137861188 | ||
|
|
815d4ab9b4 | ||
|
|
5d73a5789f | ||
|
|
df05945042 | ||
|
|
6a99898ef7 | ||
|
|
a40a87276a | ||
|
|
a20cbc394e | ||
|
|
8fda87614f | ||
|
|
dffd95ce7c | ||
|
|
b0402f5a34 | ||
|
|
54b00ec5fe | ||
|
|
6dd1733bbf | ||
|
|
663c2dc1ce | ||
|
|
01eda4c178 | ||
|
|
a6b49b3f74 | ||
|
|
5b2276666f | ||
|
|
cc0cc59700 | ||
|
|
57a9decefe | ||
|
|
c0220ddd8b | ||
|
|
b31defc0a5 | ||
|
|
e7d42f83e6 | ||
|
|
db768c4886 | ||
|
|
9553187ba6 | ||
|
|
d04257eeb4 | ||
|
|
d68f425c17 | ||
|
|
49dd2cea34 | ||
|
|
fac875dba0 | ||
|
|
566ca864a0 |
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {} },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,6 +129,7 @@ export function useSessionModel() {
|
||||
layout: {
|
||||
tabs: layout.tabs,
|
||||
view: layout.view,
|
||||
tabKey: layout.tabKey,
|
||||
},
|
||||
ownership: createSessionOwnership(layout.sessionKey),
|
||||
tabs: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 []
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user