Compare commits

..

15 Commits

Author SHA1 Message Date
Dax Raad 373fb350d8 fix(client): avoid duplicate service contenders 2026-08-06 00:37:07 -04:00
Dax Raad 3fdfc58082 refactor(core): remove migration vacuum 2026-08-05 21:56:41 -04:00
Dax Raad 90b822119e fix(core): expose migration vacuum progress 2026-08-05 21:56:41 -04:00
Dax Raad 35ac83fd29 refactor(core): auto-start v1 migration 2026-08-05 21:56:41 -04:00
Dax Raad 675e8012b0 chore(core): remove migration test delay 2026-08-05 21:56:41 -04:00
Dax Raad b09f691cd5 feat(core): run v1 migration in background 2026-08-05 21:56:41 -04:00
Dax Raad 3dfdd9dce9 fix(core): reassign orphaned sessions 2026-08-05 21:56:41 -04:00
Dax Raad 22cd987123 fix(core): skip orphaned v1 sessions 2026-08-05 21:56:41 -04:00
Dax Raad 09417d5d51 fix(core): skip orphaned beta sessions 2026-08-05 21:56:41 -04:00
Dax Raad 01dd79f984 fix(core): accept migrated Windows paths 2026-08-05 21:56:41 -04:00
Dax Raad 9e23927648 fix: resolve v2 migration rebase 2026-08-05 21:56:41 -04:00
Dax Raad e2af71df7d test(core): update project fixture 2026-08-05 21:56:41 -04:00
Dax Raad 1024884dfd feat(core): import legacy credentials 2026-08-05 21:56:41 -04:00
Dax Raad 28e051ef16 feat(core): import previous v2 sessions 2026-08-05 21:56:41 -04:00
Dax Raad e815beb888 migration 2026-08-05 21:56:41 -04:00
169 changed files with 3963 additions and 2328 deletions
+3 -1
View File
@@ -64,6 +64,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -111,7 +112,6 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
@@ -6319,6 +6319,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
+33 -97
View File
@@ -67,12 +67,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
// extension until direct Google OpenAI-compatible routing is supported here:
// https://github.com/vercel/ai/issues/11590
// https://github.com/vercel/ai/pull/11745
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
@@ -151,33 +145,22 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
// this provider-native event shape.
const OpenAIChatUsage = Schema.StructWithRest(
Schema.Struct({
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
cached_tokens: optionalNull(Schema.Number),
cache_write_tokens: optionalNull(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
completion_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
reasoning_tokens: optionalNull(Schema.Number),
accepted_prediction_tokens: optionalNull(Schema.Number),
rejected_prediction_tokens: optionalNull(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatUsage = Schema.Struct({
prompt_tokens: Schema.optional(Schema.Number),
completion_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
Schema.Struct({
reasoning_tokens: Schema.optional(Schema.Number),
}),
),
})
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
name: optionalNull(Schema.String),
@@ -185,7 +168,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
})
const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number),
index: Schema.Number,
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
})
@@ -239,8 +222,6 @@ export interface ParserState {
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
}
// =============================================================================
@@ -578,34 +559,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
const cached = usage.prompt_tokens_details?.cached_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
inputTokens: input,
outputTokens: output,
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
})
}
const toolIndexByID = (
tools: ParserState["tools"],
pendingTools: ParserState["pendingTools"],
id: string | undefined,
) => {
if (!id) return undefined
const entry = Object.entries({ ...pendingTools, ...tools }).find(([, tool]) => tool?.id === id)
return entry ? Number(entry[0]) : undefined
}
const reasoningDelta = (
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
configuredField?: string,
@@ -688,34 +657,17 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0]
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason !== undefined && rawFinishReason !== null
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason
const finishReason = choice?.finish_reason
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
let pendingTools = state.pendingTools
let latestToolIndex = state.latestToolIndex
let nextToolIndex = state.nextToolIndex
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
@@ -742,37 +694,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) {
const matched = toolIndexByID(tools, pendingTools, tool.id || undefined)
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index =
tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index]
const pending = pendingTools[index]
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
latestToolIndex = index
nextToolIndex = Math.max(nextToolIndex, index + 1)
if (!current && (!id || !name)) {
pendingTools = {
...pendingTools,
[index]: { id: id || undefined, name: name || undefined, input: text },
}
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[index]
delete pendingTools[tool.index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
index,
tool.index,
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
@@ -804,8 +743,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
latestToolIndex,
nextToolIndex,
},
events,
] as const
@@ -862,7 +799,6 @@ export const protocol = Protocol.make({
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
nextToolIndex: 0,
}),
step,
onHalt: finishEvents,
+23 -22
View File
@@ -5,7 +5,6 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { ProviderShared } from "../protocols/shared"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure")
@@ -20,7 +19,7 @@ export type LanguageModelOptions = AzureURL &
ProviderAuthOption<"optional"> & {
readonly apiVersion?: string
readonly queryParams?: Record<string, string>
readonly useDeploymentBasedUrls?: boolean
readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Config = LanguageModelOptions
@@ -30,22 +29,27 @@ export type Settings = ProviderPackage.Settings &
readonly apiKey?: string
readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
})
const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat",
provider: id,
auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
})
export const routes = [responsesRoute, chatRoute]
@@ -55,7 +59,7 @@ const defaults = (input: Config) => {
apiKey: _,
apiVersion: _apiVersion,
resourceName: _resourceName,
useDeploymentBasedUrls: _useDeploymentBasedUrls,
useCompletionUrls: _useCompletionUrls,
baseURL: _baseURL,
queryParams: _queryParams,
...rest
@@ -76,39 +80,37 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
endpoint: {
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
query: {
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
...input.queryParams,
},
},
})
function endpoint(input: Config, modelID: string | ModelID) {
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) {
return { baseURL, query: input.queryParams }
}
return { baseURL: `${baseURL}/v1`, query }
}
export const configure = (input: Config) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredRoute(responsesRoute, input, modelID)
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID)
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: responses,
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
responses,
chat,
configure,
@@ -129,7 +131,6 @@ const config = (settings: Settings): Config => {
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
@@ -15,7 +15,8 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
export const id = ProviderID.make("google-vertex")
// models.dev uses this provider id even though the API contract is Anthropic Messages.
export const id = ProviderID.make("google-vertex-anthropic")
export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & {
-21
View File
@@ -209,27 +209,6 @@ describe("provider package entrypoints", () => {
expect(chat.route.id).toBe("azure-openai-chat")
})
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
query: { "api-version": "2025-01-01-preview" },
})
expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
expect(gateway.route.endpoint.query).toBeUndefined()
})
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", {
@@ -56,14 +56,13 @@ describe("Google Vertex providers", () => {
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6")
const response = yield* LLMClient.generate(
LLM.request({
model,
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(
@@ -98,7 +97,6 @@ describe("Google Vertex providers", () => {
),
)
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.")
}),
)
@@ -7,7 +7,7 @@ import { compileRequest } from "../../src/route/client"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse } from "../lib/http"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
const Json = Schema.fromJsonString(Schema.Unknown)
@@ -253,106 +253,4 @@ describe("OpenAI-compatible Chat route", () => {
})
}),
)
it.effect("accepts nullable usage and preserves provider fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
completion_tokens_details: {
reasoning_tokens: null,
accepted_prediction_tokens: null,
rejected_prediction_tokens: null,
},
cost: "0.001",
}),
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
providerMetadata: {
openai: {
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
cost: "0.001",
},
},
})
}),
)
it.effect("assembles indexless parallel tool calls across sparse chunks", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({
tool_calls: [
{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
{ index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
],
}),
deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
deltaChunk({}, "tool_calls"),
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
}),
)
it.effect("treats an empty finish reason as terminal", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
}),
)
it.effect("rejects content after a terminal chunk", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
),
),
),
Effect.flip,
)
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
}),
)
})
+220
View File
@@ -0,0 +1,220 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
@@ -45,6 +45,10 @@ describe("timeline fixture validation", () => {
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
})
test("uses the projected tool ID as its call ID", () => {
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
})
})
if (false) {
@@ -2,8 +2,15 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
import type {
AssistantMessage,
Message,
Part,
ToolPart,
ToolState,
UserMessage,
} from "../../../src/types"
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -18,7 +25,7 @@ export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type Session = SessionInfo
type Session = SessionV1Info
type GlobalEvent = {
directory: string
project?: string
@@ -530,11 +537,11 @@ export function project() {
export function session(input: Partial<Session> = {}): Session {
return {
id: sessionID,
slug: "timeline-stability",
projectID,
location: { directory },
directory,
title,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
...input,
}
@@ -177,8 +177,7 @@ test("shows all and expands historical diff summary without overlap", async ({ p
const firstUser = userMessage(undefined, {
summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`,
status: "modified",
file: `src/diff-${index}.ts`,
additions: 1,
deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
const followingTextID = `${id("msg_assistant", 10)}:text:0`
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
@@ -295,7 +295,7 @@ function contextTool(
sessionID,
messageID,
type: "tool",
callID: partID,
callID: `call_${partID}`,
tool,
state: {
status,
@@ -23,7 +23,7 @@ type EventPayload = {
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
@@ -86,8 +86,28 @@ test("animates todo opening without replaying it across session tabs", async ({
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
await writeProbe(page)
await switchTab(page, titleB)
+3 -2
View File
@@ -80,8 +80,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider") return json(route, providerConfig(config))
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
+17 -5
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = {
id: number
url: string
path: "/api/event"
path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string>
openedAt: number
endedAt?: number
@@ -174,7 +174,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || url.pathname !== "/api/event")
if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request)
const id = ++nextConnectionID
@@ -190,9 +193,18 @@ export async function installSseTransport<T>(
record.controller = controller
connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
),
)
request.signal.addEventListener(
"abort",
() => {
+1 -1
View File
@@ -40,7 +40,6 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
@@ -57,6 +56,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
+21 -1
View File
@@ -155,7 +155,7 @@ function LegacyTargetSessionRedirect() {
)
createEffect(() => {
const directory = current()?.session.location.directory
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
@@ -238,6 +238,26 @@ function UiI18nBridge(props: ParentProps) {
}
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const server = useServer()
const settings = useSettings()
createEffect(() => {
if (settings.general.newLayoutDesigns()) return
const current = server.current
if (!current) return
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
if (protocol !== "v2") return
const next = global.servers.list().find((s) => {
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
})
if (!next) return
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
})
return <>{props.children}</>
}
@@ -14,6 +14,7 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = {
id: string
@@ -145,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -256,6 +257,7 @@ export function createServerSessionEntries(props: {
.load(search, current.signal)
.then((result) =>
result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived)
.map((session) => {
const project =
@@ -264,9 +266,9 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory),
description: project ? displayName(project) : getFilename(session.directory),
category: props.category(),
directory: session.location.directory,
directory: session.directory,
sessionID: session.id,
server: props.server,
project,
@@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -418,7 +418,7 @@ function ProviderConnection(props: {
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
.currentApi.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
@@ -547,7 +547,7 @@ function ProviderConnection(props: {
}
dispatch({ type: "auth.pending" })
await serverSDK()
.api.integration.oauth.connect({
.currentApi.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({
await serverSDK().currentApi.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
setFormStore("error", undefined)
const result = await serverSDK()
.api.integration.oauth.complete({
.currentApi.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
@@ -1044,7 +1044,7 @@ function ProviderConnection(props: {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.api.integration.oauth.status({
.currentApi.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
@@ -130,9 +130,26 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
}
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
throw new Error(`Custom provider ${result.providerID} is unavailable`)
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK().legacy.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
}
await serverSync().updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
return result
},
onSuccess: (result) => {
dialog.close()
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
@@ -160,7 +160,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
+1 -1
View File
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
@@ -66,21 +66,23 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
let pathArea: HTMLDivElement | undefined
let navigation = 0
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -102,7 +104,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
const files = await sdk.currentApi.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
@@ -132,7 +134,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file
return sdk.currentApi.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
@@ -57,21 +57,23 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.catch(() => undefined)
},
{ initialValue: undefined },
)
@@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
@@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
@@ -345,7 +360,9 @@ export function useServerManagementController(options: { onSelect?: () => void;
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
const list = settings.general.newLayoutDesigns()
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
@@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
api: {
currentApi: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
@@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () =
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
currentApi: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
@@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.api.file
const request = args.sdk.currentApi.file
.list({ location: { directory: key } })
.then((result) => result.data)
.catch(() => [])
@@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
const results = await args.sdk.currentApi.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
+18 -6
View File
@@ -9,7 +9,6 @@ import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,9 +71,23 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
// TODO: Restore project edits when the V2 client exposes a project update API.
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
throw new Error(`Project ${props.project.id} cannot be updated`)
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.legacy.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
.then((result) => result.data)
if (!project) return
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
@@ -88,7 +101,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function submit(event: SubmitEvent) {
event.preventDefault()
if (!supported || save.isPending) return
if (save.isPending) return
save.mutate()
}
@@ -98,7 +111,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
folderName,
defaultName,
save,
supported,
submit,
drop,
dragOver,
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
import type { ReferenceInfo } from "@/types"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
import type { ReferenceInfo } from "@/types"
export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,15 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
type ContextFile = {
key: string
@@ -31,12 +31,6 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
const switchedModels: Array<{
sessionID: string
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
@@ -99,22 +93,10 @@ const clientFor = (directory: string) => {
}
},
prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
switchedAgents.push(input)
},
switchModel: async (input: {
sessionID: string
model: { id: string; providerID: string; variant?: string }
}) => {
sessionRequestOrder.push("model")
switchedModels.push(input)
},
command: async (input: unknown) => {
sentCommands.push(input)
},
@@ -143,6 +125,13 @@ beforeAll(async () => {
useSearchParams: () => [search, () => undefined],
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
showToast: () => 0,
@@ -208,8 +197,13 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
client: rootClient,
api: rootClient.api,
currentApi: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return () => sdk
},
@@ -297,9 +291,6 @@ beforeEach(() => {
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
@@ -457,17 +448,13 @@ describe("prompt submit worktree selection", () => {
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
})
test("switches the selected agent and model before prompting", async () => {
test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
prompt,
info: () => ({
id: "session-1",
agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" },
}),
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
@@ -496,14 +483,6 @@ describe("prompt submit worktree selection", () => {
},
})
expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
{
sessionID: "session-1",
model: { id: "model", providerID: "provider", variant: "high" },
},
])
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
@@ -1,5 +1,4 @@
import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Session } from "@/types"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
@@ -16,13 +15,14 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { getDirectory } from "@opencode-ai/core/util/path"
type PendingPrompt = {
abort: AbortController
@@ -42,10 +42,9 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
api: DirectorySDK["currentApi"]["session"]
serverSync: ServerSync
sync: DirectorySync
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
@@ -158,25 +157,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
}
if (
session?.model?.providerID !== input.draft.model.providerID ||
session.model.id !== input.draft.model.modelID ||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
) {
await input.api.switchModel({
sessionID: input.draft.sessionID,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
})
}
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
@@ -217,9 +197,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
>
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
@@ -283,7 +261,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.currentApi.session.interrupt({ sessionID })
.catch(() => {})
}
@@ -310,10 +288,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (dir: string, info: SessionInfo) => {
const seed = (dir: string, info: Session) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: SessionInfo[]) => {
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
@@ -367,10 +345,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await sdk()
.api.projectCopy.create({
.currentApi.projectCopy.create({
projectID: sync().data.project,
strategy: "git_worktree",
directory: getDirectory(projectDirectory),
@@ -383,6 +362,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return undefined
})
if (!createdWorktree) return
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
@@ -402,11 +382,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await sdk()
.api.session.create({
.currentApi.session.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -492,7 +473,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
.currentApi.session.shell({
sessionID: session.id,
id: eventID,
command: text,
@@ -516,7 +497,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
.currentApi.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
@@ -613,10 +594,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
api: sdk().api.session,
api: sdk().currentApi.session,
sync: sync(),
serverSync: serverSync(),
session: () => input.info() ?? session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,12 +125,11 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
async () => {
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -321,13 +320,13 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
disabled
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
@@ -335,15 +334,15 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
// TODO: Restore config writes when the V2 client exposes a config API.
// void serverSync().updateConfig({ shell: option.value })
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,6 +39,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -96,7 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
}
const disableProvider = async (providerID: string, name: string) => {
return
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
@@ -119,13 +121,18 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK()
.api.integration.get({ integrationID: providerID })
.currentApi.integration.get({ integrationID: providerID })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })),
)
showToast({
variant: "success",
@@ -216,7 +223,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
)}
</For>
<Show when={false}>
<Show when={protocol() === "v1"}>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
@@ -33,7 +33,7 @@ export const DialogSettings: Component<{
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
@@ -10,6 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -90,13 +91,15 @@ export const SettingsGeneralV2: Component<{
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const dir = createMemo(() => {
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => {
const value = dir()
@@ -120,11 +123,8 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
// TODO: Restore executable shell discovery when the V2 client exposes it.
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -279,10 +279,11 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
@@ -298,7 +299,8 @@ export const SettingsGeneralV2: Component<{
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -36,6 +36,7 @@ export const SettingsProvidersV2: Component<{
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -80,7 +81,8 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -93,7 +95,7 @@ export const SettingsProvidersV2: Component<{
}
const disableProvider = async (providerID: string, name: string) => {
return
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
@@ -116,15 +118,20 @@ export const SettingsProvidersV2: Component<{
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.api.integration.get({ integrationID: providerID, location })
.currentApi.integration.get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) =>
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }),
),
)
showToast({
@@ -222,7 +229,7 @@ export const SettingsProvidersV2: Component<{
)}
</For>
<Show when={false}>
<Show when={protocol() === "v1"}>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
@@ -16,7 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServerProtocol } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -251,7 +251,6 @@ function ServerStatusList(props: { state: ServerStatusState }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const sdk = useSDK()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
@@ -259,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage()
const navigate = useNavigate()
const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => {
showToast({
@@ -279,7 +279,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogRun += 1
})
const sortedServers = createMemo(() => {
const list = global.servers.list()
const list = settings.general.newLayoutDesigns()
? global.servers.list()
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle()
@@ -289,11 +291,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource(
() => (props.shown() ? sdk().directory : undefined),
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
const plugins = createMemo(() =>
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -318,11 +318,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Show when={true}>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<Show when={protocol() === "v1"}>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
@@ -485,9 +488,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
</Show>
<Show when={true}>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+26 -4
View File
@@ -11,11 +11,13 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
import { authTokenFromCredentials } from "@/utils/server"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -174,8 +176,13 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server
const directory = sdk().directory
const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -234,7 +241,7 @@ export const Terminal = (props: TerminalProps) => {
const pushSize = async (cols: number, rows: number) => {
return sdk()
.api.pty.update({
.currentApi.pty.update({
ptyID: id,
location: { directory },
size: { cols, rows },
@@ -516,7 +523,7 @@ export const Terminal = (props: TerminalProps) => {
const gone = async () => {
return sdk()
.api.pty.get({ ptyID: id, location: { directory } })
.currentApi.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
@@ -526,8 +533,23 @@ export const Terminal = (props: TerminalProps) => {
}
const connectToken = async () => {
// TODO: Add PTY tickets when the V2 client exposes a connect-token API.
return undefined
const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url)
endpoint.searchParams.set("location[directory]", directory)
const response = await (platform.fetch ?? globalThis.fetch)(endpoint, {
method: "POST",
headers: {
"x-opencode-ticket": "1",
...(password
? { Authorization: `Basic ${authTokenFromCredentials({ username, password })}` }
: undefined),
},
})
if (response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
if (!response.ok) throw new Error(`PTY connect ticket failed with ${response.status}`)
const result = (await response.json()) as { data?: { ticket?: string } }
if (!result.data?.ticket) throw new Error("PTY connect ticket response did not include a ticket")
return result.data.ticket
}
const retry = (err: unknown) => {
@@ -4,12 +4,12 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/utils/session-title"
import type { Session } from "@/types"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => SessionInfo | undefined
session: () => Session | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
@@ -57,19 +57,19 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle
return session ? displayLabel(session) : props.fallbackTitle
})
const projectName = createMemo(() => {
const session = props.session()
if (!session) return
return displayName(project() ?? { worktree: session.location.directory })
return displayName(project() ?? { worktree: session.directory })
})
const previewPath = createMemo(() => {
const session = props.session()
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory
return home ? session.directory.replace(home, "~") : session.directory
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
@@ -235,7 +235,7 @@ export function TabNavItem(props: {
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().location.directory}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
@@ -306,7 +306,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session()?.title,
title: title(),
path: previewPath(),
serverName: serverLabel(),
}}
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
function SessionTabSlot(props: {
tab: SessionTab
@@ -27,7 +27,7 @@ function SessionTabSlot(props: {
index: () => number
active: () => boolean
forceTruncate: boolean
session: () => SessionInfo | undefined
session: () => Session | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -105,7 +105,7 @@ function SessionTabEntry(props: {
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
await ctx.sdk.currentApi.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
@@ -127,7 +127,7 @@ function SessionTabEntry(props: {
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(value.location.directory)
.ensureDirSyncContext(value.directory)
.session.sync(value.id)
.catch(() => {})
.finally(dispose)
@@ -144,7 +144,7 @@ function SessionTabEntry(props: {
const current = sdk()
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.location.directory),
dir: base64Encode(value.directory),
id: value.id,
})
})
+4 -2
View File
@@ -27,6 +27,7 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
const legacyTitlebarHeight = 40
const v2TitlebarHeight = 36
@@ -191,8 +192,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.api.session
sdk.currentApi.session
.get({ sessionID: route.sessionId })
.then(normalizeSessionInfo)
.catch(() => {}),
)
@@ -254,7 +256,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return
}
+16 -8
View File
@@ -1,11 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Session } from "@/types"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
@@ -26,6 +26,7 @@ export const createDirSyncContext = (
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, {
@@ -46,7 +47,7 @@ export const createDirSyncContext = (
const index = (sessionID: string) => {
const session = serverSync.session.get(sessionID)
if (!session || session.location.directory !== directory) return
if (!session || session.directory !== directory) return
const [store, setStore] = current()
const result = Binary.search(store.session, session.id, (item) => item.id)
if (result.found) {
@@ -74,13 +75,13 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: SessionInfo) {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
if (session?.directory === directory) return session
},
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
@@ -123,8 +124,9 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data
.map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
@@ -132,8 +134,14 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
// TODO: Restore archiving when the V2 client exposes a session archive API.
void sessionID
await serverSDK.legacy.session.archive(sessionID, directory)
current()[1](
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (session) => session.id)
if (match.found) draft.splice(match.index, 1)
}),
)
},
},
mcp: {
+3 -3
View File
@@ -81,7 +81,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalizeDir: path.normalizeDir,
list: (dir) =>
sdk()
.api.file.list({ path: dir, location: { directory: scope() } })
.currentApi.file.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
@@ -188,7 +188,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk()
.api.file.read({ path: file, location: { directory } })
.currentApi.file.read({ path: file, location: { directory } })
.then((data) => {
if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.api.file.find(
.currentApi.file.find(
{
location: { directory: sdk().directory },
query,
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
bootstrapDirectory,
loadAgentsQuery,
loadCommands,
loadPathQuery,
@@ -10,19 +14,139 @@ import {
loadProvidersQuery,
loadReferencesQuery,
} from "./bootstrap"
import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"]
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const api = {
agent: { list: async () => ({ location: {}, data: [] }) },
provider: { list: async () => ({ location: {}, data: [] }) },
model: {
list: async () => ({ location: {}, data: [] }),
default: async () => ({ location: {}, data: null }),
},
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
project: {
list: async () => [],
current: async () => ({ id: "project", directory: "/project" }),
},
question: { request: { list: async () => ({ location: {}, data: [] }) } },
reference: { list: async () => ({ location: {}, data: [] }) },
vcs: { get: async () => ({ location: {}, data: {} }) },
} as unknown as ServerApi
function directoryState() {
return createStore<State>({
status: "loading",
agent: [],
command: [],
reference: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
mcp_resource: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
}
describe("bootstrapDirectory", () => {
test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
const currentApi = {
...api,
command: {
list: async () => {
mcpReads.push("command")
return { location: {}, data: [] }
},
},
mcp: {
list: async () => {
mcpReads.push("status")
return { location: {}, data: [] }
},
resource: {
catalog: async () => {
mcpReads.push("resource")
return { location: {}, data: { resources: [], templates: [] } }
},
},
},
} as unknown as ServerApi
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: true,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
api: currentApi,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v1"),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as Parameters<typeof loadPathQuery>[2]
const api = {} as CatalogApi
const location = {} as ServerApi["location"]
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
"local",
"/repo",
"path",
])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
"https://debian.example",
"/repo",
"path",
])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
@@ -57,21 +181,6 @@ describe("query keys", () => {
expect(result.connected).toEqual(["openai"])
})
test("loads current location metadata", async () => {
const calls: unknown[] = []
const api = {
get: async (input: unknown) => {
calls.push(input)
return { directory: "/repo/subpath", project: { id: "project", directory: "/repo" } }
},
} as ServerApi["location"]
const result = await new QueryClient().fetchQuery(loadPathQuery(ServerScope.local, "/repo/subpath", api))
expect(calls).toEqual([{ location: { directory: "/repo/subpath" } }])
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads agents from the current location-scoped endpoint", async () => {
const calls: unknown[] = []
const api = {
@@ -1,9 +1,14 @@
import type {
Config,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@/types"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type {
AgentListInput,
AgentListOutput,
@@ -13,16 +18,12 @@ import type {
CommandListOutput,
LocationGetInput,
LocationGetOutput,
PermissionRequest,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
ReferenceListInput,
ReferenceListOutput,
ReferenceInfo,
QuestionRequest,
SessionApi,
SessionInfo,
} from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -34,6 +35,7 @@ import type { ServerSession } from "../server-session"
import {
cmp,
normalizeAgentList,
normalizePermissionRequest,
normalizeProjectInfo,
normalizeProviderList,
} from "./utils"
@@ -42,6 +44,8 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { normalizeSessionInfo } from "@/utils/session"
import type { ServerProtocol } from "@/utils/server-protocol"
import type { ServerApi } from "@/utils/server"
type GlobalStore = {
@@ -103,11 +107,11 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope) =>
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, "config"],
// TODO: Restore config loading when the V2 client exposes a config API.
queryFn: async (): Promise<Config> => ({}),
queryFn: () => retry(() => legacy.config.global()),
enabled,
})
type ProjectApi = {
@@ -138,7 +142,9 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
})
export async function bootstrapGlobal(input: {
legacy: LegacyCapabilities
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@@ -146,18 +152,22 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
const protocol = await input.protocol
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient.fetchQuery(
loadPathQuery(input.scope, null, input.serverAPI.location),
),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)),
]
].filter(Boolean) as Array<() => Promise<unknown>>
await runAll(slow)
// showErrors({
// errors: errors(),
@@ -181,7 +191,7 @@ function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) {
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
setStore("session", (list) => {
const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id)
@@ -206,7 +216,9 @@ function warmSessions(input: {
if (ids.length === 0) return Promise.resolve()
return Promise.all(
ids.map((sessionID) =>
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)),
retry(() => input.api.get({ sessionID })).then((session) =>
mergeSession(input.setStore, normalizeSessionInfo(session)),
),
),
).then(() => undefined)
}
@@ -267,7 +279,7 @@ export const loadPathQuery = (
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: () =>
api.get(directory ? { location: { directory } } : undefined).then((location) => ({
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
@@ -292,6 +304,7 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
legacy: LegacyCapabilities
api: CatalogApi & {
readonly agent: AgentListApi
readonly command: CommandListApi
@@ -317,6 +330,7 @@ export async function bootstrapDirectory(input: {
}
queryClient: QueryClient
session?: ServerSession
protocol?: Promise<ServerProtocol>
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
@@ -338,6 +352,13 @@ export async function bootstrapDirectory(input: {
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)),
(await input.protocol) === "v1" &&
(() =>
retry(() =>
input.legacy.config
.directory(input.directory)
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -346,7 +367,9 @@ export async function bootstrapDirectory(input: {
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
.ensureQueryData(
loadPathQuery(input.scope, input.directory, input.api.location),
)
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
@@ -364,7 +387,7 @@ export async function bootstrapDirectory(input: {
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
@@ -378,7 +401,7 @@ export async function bootstrapDirectory(input: {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
@@ -412,7 +435,7 @@ export async function bootstrapDirectory(input: {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const lspQuery = useQuery(() => {
const options = input.queryOptions.lsp(key)
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
})
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -14,7 +13,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
updated: 1,
archived: input.archived,
},
}) as SessionInfo
}) as Session
const userMessage = (id: string, sessionID: string) =>
({
@@ -39,10 +38,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
action: title,
resources: ["*"],
permission: title,
patterns: ["*"],
metadata: {},
save: [],
always: [],
}) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) =>
@@ -513,7 +512,7 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
@@ -3,10 +3,14 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
import type {
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -75,7 +79,7 @@ function cleanupSessionCaches(
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: SessionInfo[],
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
@@ -124,7 +128,7 @@ export function applyDirectoryEvent(input: {
return
}
case "session.created": {
const info = (event.properties as { info: SessionInfo }).info
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
@@ -139,7 +143,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
@@ -167,7 +171,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) break
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
@@ -197,7 +201,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.usage.updated": {
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
input.setStore("session", result.index, (session) => ({
@@ -233,8 +237,9 @@ export function applyDirectoryEvent(input: {
input.setStore("session", result.index, (session) => ({
...session,
projectID: properties.projectID ?? session.projectID,
location: properties.location,
subpath: properties.subpath,
workspaceID: properties.location.workspaceID,
directory: properties.location.directory,
path: properties.subpath,
time: { ...session.time, updated: Date.now() },
}))
break
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { SessionV2Info } from "@/types"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
@@ -33,7 +33,7 @@ describe("Home V2 session index", () => {
const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
return { data: [session({ id: "root" })], cursor: {} }
return { data: { data: [session({ id: "root" })], cursor: {} } }
})
expect(result.sessions).toHaveLength(1)
@@ -48,11 +48,15 @@ describe("Home V2 session index", () => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
cursor: { next: "next-page" },
data: {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" },
},
}
}
return { data: [session({ id: "page-2" })], cursor: {} }
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
},
0,
controller.signal,
@@ -72,7 +76,7 @@ describe("Home V2 session index", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionInfo
} as unknown as SessionV2Info
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
@@ -81,9 +85,11 @@ describe("Home V2 session index", () => {
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
location: { directory: "/project" },
expect.objectContaining({
id: "root",
slug: "root",
version: "",
directory: "/project",
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
@@ -99,17 +105,17 @@ describe("Home V2 session index", () => {
const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
location: { directory: index % 2 === 0 ? "/one" : "/two" },
directory: index % 2 === 0 ? "/one" : "/two",
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
@@ -139,8 +145,10 @@ describe("Home V2 session index", () => {
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
})
test("refetches after reconnect", () => {
test("refetches after reconnect, disposal, and session moves", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
})
@@ -1,5 +1,4 @@
import type { Event } from "@/types"
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
@@ -8,29 +7,31 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info?: SessionInfo }
properties: { sessionID: string; info: Session }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: SessionInfo[]
sessions: Session[]
eventSequence: number
}
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<SessionsResponse>,
) => Promise<HomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionInfo[] = []
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
@@ -42,7 +43,7 @@ export async function loadHomeSessionIndex(
},
{ signal },
)
const page = response
const page = response.data!
data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence }
@@ -74,7 +75,10 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected }
return { connected, refetch: false }
return {
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
}
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
@@ -125,28 +129,23 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
// current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }) and replace this full-table scan.
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [item]
return [toLegacySummary(item)]
})
}
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
}
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID))
if (event.type === "session.deleted") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (!info) return sessions
if (info.parentID || typeof info.time.archived === "number") {
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
@@ -154,3 +153,22 @@ export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessio
if (index === -1) return [...sessions, info]
return sessions.with(index, info)
}
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: session.title,
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -1,5 +1,4 @@
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,4 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
@@ -8,7 +9,7 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
order: "desc",
})
return {
data: result.data,
data: result.data.map(normalizeSessionInfo),
limit: input.limit,
limited: true,
} as const
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, Session } from "@/types"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -11,7 +11,7 @@ const session = (input: { id: string; parentID?: string; created: number; update
updated: input.updated,
archived: input.archived,
},
}) as SessionInfo
}) as Session
describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => {
@@ -1,22 +1,21 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest, Session } from "@/types"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: SessionInfo) {
export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created
}
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) {
export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) {
if (limit <= 0) return [] as SessionInfo[]
const selected: SessionInfo[] = []
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[]
const selected: Session[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
@@ -32,7 +31,7 @@ export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutof
}
export function trimSessions(
input: SessionInfo[],
input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
@@ -5,17 +5,15 @@ import type {
Message,
Part,
Path,
Todo,
VcsInfo,
} from "@/types"
import type {
FileDiffInfo,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
SessionInfo,
Session,
SessionStatus,
} from "@opencode-ai/client/promise"
Todo,
VcsInfo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
@@ -44,7 +42,7 @@ export type State = {
provider: NormalizedProviderListResponse
config: Config
path: Path
session: SessionInfo[]
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
@@ -5,7 +5,7 @@ import type {
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -43,6 +43,30 @@ describe("normalizeAgentList", () => {
})
})
describe("normalizePermissionRequest", () => {
test("adapts the current permission request to app state", () => {
expect(
normalizePermissionRequest({
id: "permission-1",
sessionID: "session-1",
action: "read",
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
sessionID: "session-1",
permission: "read",
patterns: ["README.md"],
always: ["*.md"],
metadata: { path: "README.md" },
tool: { messageID: "message-1", callID: "call-1" },
})
})
})
describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList(
+32 -2
View File
@@ -2,9 +2,10 @@ import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
PermissionRequest,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
@@ -35,13 +36,42 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
}))
}
type LegacyPermissionRequest = Extract<Event, { type: "permission.asked" }>["properties"]
export function normalizePermissionRequest(input: PermissionRequest | LegacyPermissionRequest): LegacyPermissionRequest {
if ("permission" in input) return input
return {
id: input.id,
sessionID: input.sessionID,
permission: input.action,
patterns: input.resources,
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
defaultModel?: ModelDefaultOutput["data"],
): NormalizedProviderListResponse {
if (!Array.isArray(providers)) {
return providers
return {
...providers,
all: new Map(
providers.all.map((provider) => [
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
),
},
]),
),
}
}
const all = new Map<string, Provider>()
+12 -3
View File
@@ -340,7 +340,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
void removePersisted(target, platform)
if (scope !== ServerScope.local) continue
const legacyKey = `${dir}/${entry["legacy"]}${session ? "/" + session : ""}.${entry.version}`
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey }, platform)
}
}
@@ -572,8 +572,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const projectID = project.id
void (async () => {
// TODO: Restore project color updates when the V2 client exposes a project update API.
void projectID
const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return
return sdk.legacy.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
if (!result) return
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
})
})().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
+3 -3
View File
@@ -360,7 +360,7 @@ function createServerNotificationState(input: {
const handleSessionError = (
directory: string,
event: { properties: EventSessionError["properties"] },
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
@@ -372,7 +372,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors())
}
const error = event.properties.error
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
@@ -393,7 +393,7 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, Session } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
}) as Session
const permission = (sessionID: string) =>
({
+8 -3
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync"
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
acceptKey,
directoryAcceptKey,
@@ -132,7 +133,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (draft) return draft.directory
if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.location.directory
return selected().sync.session.lineage.peek(params.id)?.session.directory
})
createEffect(() => {
@@ -211,6 +212,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
)
function enableConfiguredDirectory(directory: string) {
if (input.sdk.protocolKind() !== "v1") return
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
@@ -248,6 +250,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
sessionID: request.sessionID,
requestID: request.permissionID,
reply: request.response,
location: request.directory ? { directory: request.directory } : undefined,
})
.catch(() => {
responded.delete(request.permissionID)
@@ -255,7 +258,9 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}
const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
}
function respondOnce(permission: PermissionRequest, directory?: string) {
+3 -5
View File
@@ -1,9 +1,10 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js"
import { type DirectorySDK, useServerSDK } from "./server-sdk"
export type { DirectorySDK } from "./server-sdk"
const context = createSimpleContext({
export type { DirectorySDK }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string | Accessor<string> }) => {
@@ -14,6 +15,3 @@ const context = createSimpleContext({
})
},
})
export const useSDK: () => Accessor<DirectorySDK> = context.use
export const SDKProvider = context.provider
+154 -25
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@@ -15,7 +16,7 @@ describe("resumeStreamAfterPageShow", () => {
})
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
test("preserves V2 events while adapting permission requests for existing consumers", () => {
const current = {
id: "evt_1",
created: 1,
@@ -30,38 +31,55 @@ describe("adaptServerEvent", () => {
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
id: "evt_1",
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
},
current,
})
})
})
describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo",
payload: adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent),
payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value },
} as Event,
})
test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
test("merges adjacent deltas for the same field", () => {
const first = delta("hello ")
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) =>
adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
})
test("coalesces current tool input deltas by tool ID", () => {
@@ -84,16 +102,127 @@ describe("current event buffering", () => {
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
])
})
test("preserves current event order when enqueuing", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = []
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
test("preserves event ID order across interleaved deltas", () => {
const first = delta("a")
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
const result = coalesceServerEvents([first, other, last])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "message.updated",
properties: {
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"session.deleted",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
enqueue("busy")
expect(events).toHaveLength(2)
})
})
+157 -18
View File
@@ -1,16 +1,24 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types"
import type { Event, PermissionRequest } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import {
createCompatibleApi,
createLegacyCapabilities,
type CompatibleApi,
type LegacyCapabilities,
} from "@/utils/server-compat"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
@@ -24,10 +32,44 @@ type CurrentDelta = Extract<
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
} satisfies PermissionRequest,
current: event,
}
}
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
return undefined
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
const key = coalescedKey(event)
const previous = queue[queue.length - 1]
if (key && previous && coalescedKey(previous) === key) {
queue[queue.length - 1] = event
return false
}
queue.push(event)
return true
}
@@ -63,7 +105,33 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
output.push(event)
return
}
output.push(event)
if (event.payload.type !== "message.part.delta") {
output.push(event)
return
}
const props = event.payload.properties
const previous = output[output.length - 1]
if (
!previous ||
previous.payload.type !== "message.part.delta" ||
previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
})
return output
}
@@ -98,13 +166,21 @@ type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]:
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
protocol: Promise<ServerProtocol>
protocolKind: Accessor<ServerProtocol | undefined>
url: string
api: ServerApi
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
legacy: LegacyCapabilities
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
@@ -123,6 +199,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const [protocolKind] = createResource(
() => protocol,
(value) => value,
)
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
@@ -240,23 +321,66 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
flush()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
return {
server,
scope,
protocol,
protocolKind,
url: server.http.url,
client: sdk,
api,
legacy: capabilities,
currentApi,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
}
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type DirectorySDK = {
scope: ServerScope
protocol: Promise<ServerProtocol>
directory: string
client: OpencodeClient
currentApi: ServerApi
api: CompatibleApi
legacy: LegacyCapabilities
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
createClient: ServerSDKBase["createClient"]
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
ensureDirSdkContext: (directory: string) => DirectorySDK
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
@@ -283,19 +407,17 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type DirectorySDK = {
scope: ServerScope
directory: string
api: ServerApi
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
export function useServerProtocol() {
const serverSDK = useServerSDK()
return createMemo(() => serverSDK().protocolKind())
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
@@ -305,11 +427,28 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
return {
scope: serverSDK.scope,
protocol: serverSDK.protocol,
directory,
api: serverSDK.api,
client,
currentApi: serverSDK.currentApi,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
legacy: createLegacyCapabilities({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
},
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
}
}
+128 -176
View File
@@ -1,26 +1,20 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type {
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { Message, Part } from "@/types"
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { Message, Part, Session } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const session = (id: string, parentID?: string): SessionInfo => ({
const session = (id: string, parentID?: string): Session => ({
id,
slug: id,
projectID: "project",
location: { directory: "/repo" },
directory: "/repo",
title: id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
version: "1",
parentID,
time: { created: 1, updated: 1 },
})
@@ -28,120 +22,19 @@ const session = (id: string, parentID?: string): SessionInfo => ({
type UserMessage = Extract<Message, { role: "user" }>
type AssistantMessage = Extract<Message, { role: "assistant" }>
type TextPart = Extract<Part, { type: "text" }>
type CurrentToolObject = Extract<SessionMessageAssistantTool["state"], { status: "running" }>["input"]
type MessageResponse = {
data: { info: Message; parts: Part[] }[]
response: { headers: Headers }
}
type SingleMessageResponse = { data: MessageResponse["data"][number] }
function sessionInfo(value: SessionInfo): SessionInfo {
return value
}
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
return data.flatMap((item): SessionMessageInfo[] => {
if (item.info.role === "user") {
return [
{
id: `${item.info.id}:agent`,
type: "agent-switched",
agent: item.info.agent,
time: item.info.time,
},
{
id: `${item.info.id}:model`,
type: "model-switched",
model: {
id: item.info.model.modelID,
providerID: item.info.model.providerID,
variant: item.info.model.variant,
},
time: item.info.time,
},
{
id: item.info.id,
type: "user",
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
},
]
}
return [{
id: item.info.id,
type: "assistant",
agent: item.info.agent,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
},
]
if (part.type !== "tool") return []
const state: SessionMessageAssistantTool["state"] = (() => {
if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
if (part.state.status === "running")
return {
status: "running" as const,
input: part.state.input as CurrentToolObject,
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
}
if (part.state.status === "error")
return {
status: "error" as const,
input: part.state.input as CurrentToolObject,
error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [
{
id: part.id,
type: "tool" as const,
name: part.tool,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
},
]
}),
time: item.info.time,
cost: item.info.cost,
tokens: item.info.tokens,
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
}]
})
}
function currentPage(value: MessageResponse) {
return {
data: currentMessages(value.data).toReversed(),
cursor: { next: value.response.headers.get("x-next-cursor") ?? undefined },
}
}
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
id,
sessionID: "child",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model", variant: undefined },
model: { providerID: "provider", modelID: "model" },
...input,
})
@@ -153,24 +46,21 @@ const assistantMessage = (id: string, parentID: string, input: Partial<Assistant
parentID,
modelID: "model",
providerID: "provider",
variant: undefined,
mode: "build",
agent: "build",
path: { cwd: "", root: "" },
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
error: undefined,
finish: undefined,
...input,
})
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
id: "part",
sessionID: "child",
messageID,
type: "text",
text: "text",
...input,
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
})
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
@@ -184,27 +74,19 @@ const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
let index = 0
const pages = responses.map((value) =>
value instanceof Promise ? value.then(currentPage) : Promise.resolve(currentPage(value)),
)
const requests: unknown[] = []
const waiting = new Map<number, () => void>()
const client = {
session: {
get: async () => sessionInfo(session("child", "root")),
message: async () => {
throw new Error("Unexpected single message request")
},
},
message: {
list: async (input: unknown) => {
get: async () => ({ data: session("child", "root") }),
messages: (input: unknown) => {
requests.push(input)
waiting.get(requests.length)?.()
waiting.delete(requests.length)
return pages[index++]!
return responses[index++]
},
},
} as unknown as { session: SessionApi; message: MessageApi }
} as unknown as OpencodeClient
return Object.assign(client, {
requests,
requested(count: number) {
@@ -225,22 +107,19 @@ function rootMessageClient(
const rootWaiting = new Map<number, () => void>()
const client = {
session: {
get: async () => sessionInfo(session("child", "root")),
message: async (input: unknown) => {
get: async () => ({ data: session("child", "root") }),
messages: (input: unknown) => {
requests.push(input)
return pages[pageIndex++]
},
message: (input: unknown) => {
rootRequests.push(input)
rootWaiting.get(rootRequests.length)?.()
rootWaiting.delete(rootRequests.length)
const value = await roots[rootIndex++]!
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
return roots[rootIndex++]
},
},
message: {
list: async (input: unknown) => {
requests.push(input)
return currentPage(await pages[pageIndex++]!)
},
},
} as unknown as { session: SessionApi; message: MessageApi }
} as unknown as OpencodeClient
return Object.assign(client, {
requests,
rootRequests,
@@ -262,7 +141,7 @@ const retryImmediately: typeof retry = async (task, options = {}) => {
}
}
function setup(sessions: Record<string, SessionInfo>) {
function setup(sessions: Record<string, Session>) {
const get: unknown[] = []
const messages: unknown[] = []
const client = {
@@ -270,19 +149,16 @@ function setup(sessions: Record<string, SessionInfo>) {
get: async (input: unknown) => {
get.push(input)
const id = (input as { sessionID: string }).sessionID
return sessionInfo(sessions[id]!)
return { data: sessions[id] }
},
message: async () => {
throw new Error("Unexpected single message request")
},
},
message: {
list: async (input: unknown) => {
messages: async (input: unknown) => {
messages.push(input)
return currentPage(response())
return response()
},
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
} as unknown as { session: SessionApi; message: MessageApi }
} as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) }
}
@@ -354,7 +230,7 @@ describe("server session", () => {
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
@@ -369,19 +245,54 @@ describe("server session", () => {
content: [{ type: "text", text: "hi" }],
time: { created: 2, completed: 3 },
}
const client = {
session: {
messages: () => {
throw new Error("legacy message endpoint called")
},
},
} as unknown as OpencodeClient
const messageApi = {
list: async (input: unknown) => {
requests.push(input)
return { data: [assistant, user], cursor: { previous: null, next: null } }
},
} as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi)
const store = createServerSession(client, {} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.history.more("root")).toBe(false)
})
test("replaces stale current projections on complete refreshes", async () => {
const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
const pages = [
{ data: [first], cursor: { previous: null, next: null } },
{ data: [second], cursor: { previous: null, next: null } },
{ data: [], cursor: { previous: null, next: null } },
]
const messageApi = {
list: async () => pages.shift()!,
} as unknown as MessageApi
const sessionApi = { get: async () => session("root") } as unknown as SessionApi
const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root).toEqual([])
expect(store.data.message.root).toEqual([])
})
test("extends a current page to include the user for split assistant turns", async () => {
@@ -410,7 +321,7 @@ describe("server session", () => {
return pages.shift()!
},
} as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi)
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
@@ -426,14 +337,50 @@ describe("server session", () => {
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
})
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
describe.skip("V1 assistant parent projections", () => {
test("indexes V1 messages for the current timeline projection", async () => {
const user = userMessage("message-1", { sessionID: "root" })
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
const client = messageClient(
response([
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
]),
)
const messageApi = {
list: () => {
throw new Error("current message endpoint called")
},
} as unknown as MessageApi
const store = createServerSession(client, {} as SessionApi, messageApi, {
protocol: Promise.resolve("v1"),
})
store.remember(session("root"))
await store.sync("root")
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.session_message.root).toMatchObject([
{ id: user.id, type: "user", text: "text" },
{ id: assistant.id, type: "assistant" },
])
const next = userMessage("message-3", { sessionID: "root" })
store.apply({ type: "message.updated", properties: { info: next } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(assistants.map((info) => ({ info, parts: [] }))),
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
@@ -441,16 +388,16 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false)
expect(store.history.more("child")).toBe(true)
})
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>()
const assistant = assistantMessage("message-2", "message-missing")
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
const store = createServerSession(client)
const loading = store.sync("child")
await client.rootRequested(1)
@@ -460,7 +407,7 @@ describe("server session", () => {
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
expect(store.data.message.child).toEqual([assistant])
expect(store.history.more("child")).toBe(false)
expect(store.history.more("child")).toBe(true)
})
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
@@ -474,7 +421,7 @@ describe("server session", () => {
{ info: parent, parts: [part] },
{ info: assistant, parts: [] },
]),
response([{ info: assistant, parts: [] }]),
response([{ info: assistant, parts: [] }], "older"),
],
[missing.promise],
)
@@ -496,7 +443,10 @@ describe("server session", () => {
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(assistants.map((info) => ({ info, parts: [] }))),
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
@@ -518,7 +468,10 @@ describe("server session", () => {
const client = rootMessageClient(
[
response([{ info: unrelated, parts: [] }]),
response(assistants.map((info) => ({ info, parts: [] }))),
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
@@ -537,7 +490,7 @@ describe("server session", () => {
const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(user)],
)
const store = createServerSession(client)
@@ -555,7 +508,7 @@ describe("server session", () => {
const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [freshPart])],
)
const store = createServerSession(client)
@@ -576,7 +529,7 @@ describe("server session", () => {
const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [refreshed])],
)
const store = createServerSession(client)
@@ -599,7 +552,7 @@ describe("server session", () => {
const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }]))
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
await loading
expect(client.rootRequests).toEqual([])
@@ -615,6 +568,7 @@ describe("server session", () => {
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
@@ -638,7 +592,7 @@ describe("server session", () => {
const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 }
const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])],
[response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
@@ -658,7 +612,7 @@ describe("server session", () => {
const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])],
[response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
@@ -679,7 +633,7 @@ describe("server session", () => {
const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" }
const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }])],
[response([{ info: assistant, parts: [stale] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
@@ -692,7 +646,6 @@ describe("server session", () => {
expect(store.data.part[assistant.id]).toEqual([live])
})
})
test("merges live events into the initial page", async () => {
const pending = deferredResponse()
@@ -1492,7 +1445,6 @@ describe("server session", () => {
await store.history.loadMore("child")
guard.active = false
expect(store.data.message.child).toEqual([older, latest])
})
@@ -1677,7 +1629,7 @@ describe("server session", () => {
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
expect(ctx.store.get("root")?.location.directory).toBe("/repo")
expect(ctx.store.get("root")?.directory).toBe("/repo")
expect(ctx.store.data.session_working("root")).toBe(true)
expect(ctx.get).toEqual([])
})
+132 -76
View File
@@ -1,15 +1,23 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
@@ -25,29 +33,6 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
function projectMessageSource(message: Message): SessionMessageInfo[] {
if (message.role === "user") {
return [
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
{
id: `${message.id}:model`,
type: "model-switched",
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
time: message.time,
},
{ id: message.id, type: "user", text: "", time: message.time },
]
}
return [{
id: message.id,
type: "assistant",
agent: message.agent ?? message.mode,
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
content: [],
time: message.time,
}]
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find(
(message) =>
@@ -76,6 +61,30 @@ type MessagePage = {
complete: boolean
}
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items
.slice()
.sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => {
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user" as const,
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
}
}
return {
id: item.info.id,
type: "assistant" as const,
agent: item.info.agent ?? item.info.mode,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: [],
time: item.info.time,
}
})
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = {
touchedMessages: Set<string>
@@ -174,20 +183,22 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
type ServerSessionOptions = { retry?: typeof retry }
type ServerSessionApis = { session: SessionApi; message: MessageApi }
type ServerSessionOptions = {
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
legacy?: LegacyCapabilities
}
export function createServerSession(
api: SessionApi | ServerSessionApis,
messageApiOrOptions?: MessageApi | ServerSessionOptions,
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions,
) {
const bundled = "session" in api
const sessionApi = bundled ? api.session : api
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
const [data, setData] = createStore({
info: {} as Record<string, SessionInfo | undefined>,
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>,
@@ -201,7 +212,7 @@ export function createServerSession(
return (this.session_status[id]?.type ?? "idle") !== "idle"
},
})
const requests = new Map<string, Promise<SessionInfo>>()
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
@@ -240,17 +251,17 @@ export function createServerSession(
at: {} as Record<string, number | undefined>,
})
const indexProjectedMessage = (message: Message) => {
const indexLegacyMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
setData(
"session_message",
message.sessionID,
reconcile([...current, ...projectMessageSource(message)]),
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
)
}
const remember = (session: SessionInfo) => {
const remember = (session: Session) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
infoSeen.add(session.id)
@@ -300,7 +311,12 @@ export function createServerSession(
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = sessionApi.get({ sessionID })
const request = sessionApi
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
: client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
return result.data
})
const resolved = request.then((result) => {
if (generations.get(sessionID) !== active) return result
return remember(result)
@@ -526,43 +542,72 @@ export function createServerSession(
)
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
const request = (cursor?: string) =>
(options?.retry ?? retry)(() => {
onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
})
const first = await request(before)
const pages = [first]
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
pages.push(response)
if (!response.data.length) break
if (messageApi && (await options?.protocol) !== "v1") {
const request = (cursor?: string) =>
(options?.retry ?? retry)(() => {
onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
})
const first = await request(before)
const pages = [first]
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
pages.push(response)
if (!response.data.length) break
}
const response = pages.at(-1)!
const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source,
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: !response.cursor.next,
}
}
const response = pages.at(-1)!
const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source)
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source,
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
})),
source: legacyMessageSource(items),
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: !response.cursor.next,
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
if (sessionApi && (await options?.protocol) !== "v1") {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return sessionApi.message({ sessionID, messageID })
})
const normalized = normalizeSessionMessages(sessionID, [response])
const message = normalized.messages[0]
if (!message) throw new Error(`Message not found: ${messageID}`)
return { message, parts: normalized.parts.get(messageID) ?? [] }
}
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return sessionApi.message({ sessionID, messageID })
return client.session.message({ sessionID, messageID })
})
const normalized = normalizeSessionMessages(sessionID, [response])
const message = normalized.messages[0]
if (!message) throw new Error(`Message not found: ${messageID}`)
return { message, parts: normalized.parts.get(messageID) ?? [] }
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
return {
message: cleanMessage(response.data.info),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
}
}
const replaceMessages = (sessionID: string, messages: Message[]) => {
@@ -916,8 +961,9 @@ export function createServerSession(
remember({
...info,
projectID: event.data.projectID ?? info.projectID,
location: event.data.location,
subpath: event.data.subpath,
workspaceID: event.data.location.workspaceID,
directory: event.data.location.directory,
path: event.data.subpath,
time: { ...info.time, updated: event.created },
})
if (event.type === "session.usage.updated" && info)
@@ -963,17 +1009,16 @@ export function createServerSession(
}
switch (event.type) {
case "session.created":
if ((event.properties as { info?: SessionInfo }).info)
remember((event.properties as { info: SessionInfo }).info)
remember((event.properties as { info: Session }).info)
return
case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info
const info = (event.properties as { info: Session }).info
remember(info)
if (info.time.archived) evict([info.id])
return
}
case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) return
infoSeen.delete(sessionID)
@@ -995,8 +1040,8 @@ export function createServerSession(
return
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
indexProjectedMessage(info)
const info = cleanMessage((event.properties as { info: Message }).info)
indexLegacyMessage(info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
@@ -1347,8 +1392,19 @@ export function createServerSession(
async todo(sessionID: string, request?: { force?: boolean }) {
touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return
// TODO: Restore todos when the V2 client exposes a session todo API.
setData("todo", sessionID, [])
if ((await options?.protocol) === "v2") {
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
if (!options?.legacy) return Promise.resolve()
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result, { key: "id" }))
})
})
},
history: {
more: (sessionID: string) =>
+6 -3
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
McpListInput,
McpResourceCatalogInput,
@@ -83,7 +84,7 @@ describe("active session query", () => {
})
test("does not overwrite statuses already written by events", () => {
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
const session = createServerSession({} as OpencodeClient)
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
seedActiveSessionStatuses(session, {
@@ -123,7 +124,7 @@ describe("pickDirectoriesToEvict", () => {
})
describe("loadRootSessions", () => {
test("loads a limited page of root sessions", async () => {
test("loads and normalizes a limited page of root sessions", async () => {
const calls: SessionListInput[] = []
const result = await loadRootSessions({
@@ -137,7 +138,9 @@ describe("loadRootSessions", () => {
limit: 10,
})
expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })])
expect(result.data).toEqual([
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
})
+44 -45
View File
@@ -3,6 +3,7 @@ import type {
Path,
Project,
ProviderAuthResponse,
SessionStatus,
} from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -54,11 +55,11 @@ import type {
McpResourceCatalogOutput,
McpServer,
SessionActiveOutput,
SessionStatus,
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
import type { LegacyCapabilities } from "@/utils/server-compat"
type GlobalStore = {
ready: boolean
@@ -131,11 +132,11 @@ export const loadMcpResourcesQuery = (
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string) =>
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
// TODO: Restore LSP status when the V2 client exposes an LSP API.
queryFn: async () => [],
queryFn: () => legacy.lsp.status(directory),
enabled,
})
export const loadActiveSessionsQuery = (
@@ -167,18 +168,19 @@ export function seedActiveSessionStatuses(
function makeQueryOptionsApi(
scope: ServerScope,
serverAPI: ServerApi,
protocolKind: Accessor<"v1" | "v2" | undefined>,
legacy: LegacyCapabilities,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
@@ -194,8 +196,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const session = createServerSession(
{ session: serverSDK.legacy.session },
serverSDK.currentApi.session,
serverSDK.currentApi.message,
{
protocol: serverSDK.protocol,
legacy: serverSDK.legacy,
},
)
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
serverSDK.currentApi,
serverSDK.protocolKind,
serverSDK.legacy,
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -203,7 +218,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
const active = await serverSDK.api.session.active()
const active = await serverSDK.currentApi.session.active()
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
@@ -271,7 +286,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
legacy: serverSDK.legacy,
serverAPI: serverSDK.currentApi,
protocol: serverSDK.protocol,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
@@ -311,7 +328,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
void loadCommands(directory, serverSDK.api.command)
void loadCommands(directory, serverSDK.currentApi.command)
.then((commands) => setStore("command", commands))
.catch((err) => {
showToast({
@@ -363,7 +380,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
loadRootSessions({ api: serverSDK.api.session, directory, limit })
loadRootSessions({ api: serverSDK.currentApi.session, directory, limit })
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -431,7 +448,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project,
provider: globalStore.provider,
},
api: serverSDK.api,
legacy: serverSDK.legacy,
api: serverSDK.currentApi,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -439,6 +457,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t,
queryClient,
session,
protocol: serverSDK.protocol,
})
})
@@ -451,7 +470,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
const key = directoryKey(info.location.directory)
const key = directoryKey(info.directory)
const existing = children.children[key]
if (!existing) return
applyDirectoryEvent({
@@ -476,25 +495,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.created")
void session
.resolve(event.current.data.sessionID, { force: true })
.then((info) => {
if (!session.get(info.id)) return
indexSession(info)
homeSessions.apply({
type: "session.created",
properties: { sessionID: info.id, info },
})
})
.catch(() => {})
if (event.current?.type === "session.deleted")
homeSessions.apply({
type: "session.deleted",
properties: { sessionID: event.current.data.sessionID },
})
if (event.type === "session.created" || event.type === "session.deleted") {
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event)
}
homeSessions.refresh(event.type)
if (eventType === "integration.connection.updated") void refreshProviders()
@@ -566,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (serverSDK.protocolKind() !== "v1") return
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
@@ -614,11 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: async (config: Config) => {
// TODO: Restore config updates when the V2 client exposes a config API.
// await serverSDK.api.config.update({ config })
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
},
mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -657,24 +656,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
await toggleMcp({
status,
connect: async () => {
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
await serverSDK.currentApi.mcp.connect({ server: name, location: { directory: key } })
},
disconnect: async () => {
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } })
},
authenticate: async () => {
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(
const server = (await serverSDK.currentApi.mcp.list({ location: { directory: key } })).data.find(
(item) => item.name === name,
)
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
const integration = await serverSDK.api.integration.get({
const integration = await serverSDK.currentApi.integration.get({
integrationID: server.integrationID,
location: { directory: key },
})
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({
const attempt = await serverSDK.currentApi.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
inputs: {},
+4 -5
View File
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
@@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
}
@@ -349,11 +349,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory }
const next = { title: session.title, directory: session.directory }
const current = info[key]
console.log({ tab, session, current })
if (current?.title === next.title && current.directory === next.directory) return
setInfo(key, next)
},
+12 -7
View File
@@ -248,7 +248,7 @@ function createWorkspaceTerminalSession(
setStore("all", index, (item) => ({ ...item, ...pty }))
}
const doUpdate = async () => {
await sdk.api.pty.update({
await sdk.currentApi.pty.update({
ptyID: pty.id,
location,
title: pty.title,
@@ -268,10 +268,13 @@ function createWorkspaceTerminalSession(
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const data = await sdk.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
const data = await sdk.currentApi.pty
.create({ location, title: pty.title })
.then((result) => result.data)
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return
const active = store.active === pty.id
@@ -308,7 +311,9 @@ function createWorkspaceTerminalSession(
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
const doCreate = async () => {
return sdk.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
return sdk.currentApi.pty
.create({ location, title: defaultTitle(nextNumber) })
.then((result) => result.data)
}
doCreate()
.then((data) => {
@@ -412,7 +417,7 @@ function createWorkspaceTerminalSession(
})
}
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
@@ -18,7 +18,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", location: { directory: "/workspace" } },
session: { id: "ses_1", directory: "/workspace" },
archive: async () => undefined,
remove: () => {
removed = true
@@ -36,7 +36,7 @@ test("reports archive failures without removing the session", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", location: { directory: "/workspace" } },
session: { id: "ses_1", directory: "/workspace" },
archive: async () => Promise.reject(failure),
remove: () => {
removed = true
@@ -1,8 +1,10 @@
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import type { ServerConnection } from "@/context/server"
import type { SessionInfo } from "@opencode-ai/client/promise"
type HomeSession = Pick<SessionInfo, "id" | "location">
type HomeSession = {
id: string
directory: string
}
export async function archiveHomeSession(input: {
server: ServerConnection.Key
@@ -17,7 +19,7 @@ export async function archiveHomeSession(input: {
input.remove()
notifySessionTabsRemoved({
server: input.server,
directory: input.session.location.directory,
directory: input.session.directory,
sessionIDs: [input.session.id],
})
})
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
home.server.context(conn).projects.move(worktree, index)
},
canReveal: canRevealProject,
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean
canEditProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
@@ -548,9 +549,11 @@ function HomeProjectRow(
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.canEditProject(props.server)}>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
</Show>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t(
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal}
canEditProject={props.projects.project.canEdit}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
@@ -2,8 +2,8 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { sessionLabel } from "@/utils/session-title"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session, V2SessionListResponse } from "@/types"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -26,7 +26,7 @@ import type { HomeController } from "./home-controller"
const HOME_SESSION_LIMIT = 64
export type HomeSessionRecord = {
session: SessionInfo
session: Session
project: LocalProject
projectName: string
}
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
(input, options) =>
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
data: data as unknown as V2SessionListResponse,
})),
eventSequence,
signal,
)
@@ -179,9 +182,10 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.directory)
const project =
home.project
.list()
@@ -192,7 +196,7 @@ export function createHomeSessionsController(home: HomeController) {
) ?? projectForSession(session, home.project.list(), projectByID())
const conn = home.server.focused()
if (!conn) return
const directory = project?.worktree ?? session.location.directory
const directory = project?.worktree ?? session.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.projects.open(directory)
@@ -206,16 +210,15 @@ export function createHomeSessionsController(home: HomeController) {
tabs.select(tab)
})
},
archive: async (session: SessionInfo) => {
archive: async (session: Session) => {
const conn = home.server.focused()
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.location.directory)
const [, setStore] = ctx.sync.child(session.directory)
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
// TODO: Restore archiving when the V2 client exposes a session archive API.
archive: async (_sessionID) => Promise.reject(new Error("Session archiving is unavailable")),
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
remove: () =>
setStore(
produce((draft) => {
@@ -243,17 +246,17 @@ function directories(project: LocalProject) {
}
function buildHomeSessionRecords(input: {
sessions: () => SessionInfo[]
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.location.directory)))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const directory = pathKey(session.location.directory)
const directory = pathKey(session.directory)
const project =
input
.projects()
@@ -267,7 +270,7 @@ function buildHomeSessionRecords(input: {
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
return `${pathKey(record.session.directory)}:${record.session.id}`
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
@@ -304,7 +307,7 @@ export function HomeSessionStatusController(props: {
}) {
const avatar = useSessionTabAvatarState(
props.server,
() => props.record.session.location.directory,
() => props.record.session.directory,
() => props.record.session.id,
)
return props.render({
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionLabel } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
canArchiveSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
@@ -53,8 +54,8 @@ export type HomeSessionsViewProps = {
titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
onArchiveSession: (session: SessionInfo) => Promise<void>
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
onArchiveSession: (session: Session) => Promise<void>
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
@@ -192,7 +193,7 @@ function HomeSessionLeading(props: {
</Show>
<SessionTabAvatarView
project={props.record.project}
directory={props.record.session.location.directory}
directory={props.record.session.directory}
revealProjectOnHover={props.revealProjectOnHover}
unread={props.unread}
loading={props.loading}
@@ -344,7 +345,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => sessionLabel(props.record.session))
const title = createMemo(() => displayLabel(props.record.session))
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +416,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionLabel(props.record.session))
const title = createMemo(() => displayLabel(props.record.session))
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
group-hover/session:opacity-100 focus-within:opacity-100
`}
>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<Show when={props.canArchiveSession()}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</TooltipV2>
</Show>
</div>
</Show>
</div>
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
canArchiveSession={props.sessions.session.canArchive}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
+87 -43
View File
@@ -26,7 +26,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -390,6 +390,22 @@ export default function LegacyLayout(props: ParentProps) {
}
const unsub = serverSDK().event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(serverSDK().scope, e.name)
return
}
if (e.details?.type === "worktree.failed") {
setBusy(e.name, false)
WorktreeState.failed(
serverSDK().scope,
e.name,
e.details.properties?.message ?? language.t("common.requestFailed"),
)
return
}
if (
e.details?.type === "question.replied" ||
e.details?.type === "question.rejected" ||
@@ -603,9 +619,9 @@ export default function LegacyLayout(props: ParentProps) {
const currentSessions = createMemo(() => {
const now = Date.now()
const dirs = visibleSessionDirs()
if (dirs.length === 0) return [] as SessionInfo[]
if (dirs.length === 0) return [] as Session[]
const result: SessionInfo[] = []
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = serverSync().child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
@@ -717,8 +733,8 @@ export default function LegacyLayout(props: ParentProps) {
})
}
const prefetchSession = (session: SessionInfo, priority: "high" | "low" = "low") => {
const directory = session.location.directory
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
const directory = session.directory
if (!directory) return
const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
@@ -753,7 +769,7 @@ export default function LegacyLayout(props: ParentProps) {
pumpPrefetch(directory)
}
const warm = (sessions: SessionInfo[], index: number) => {
const warm = (sessions: Session[], index: number) => {
for (let offset = 1; offset <= span; offset++) {
const next = sessions[index + offset]
if (next) prefetchSession(next, offset === 1 ? "high" : "low")
@@ -855,15 +871,13 @@ export default function LegacyLayout(props: ParentProps) {
}
}
async function archiveSession(session: SessionInfo) {
// TODO: Restore archiving when the V2 client exposes a session archive API.
void session
return
const [store, setStore] = serverSync().child(session.location.directory)
async function archiveSession(session: Session) {
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().legacy.session.archive(session.id, session.directory)
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -961,8 +975,8 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"),
category: language.t("command.category.session"),
keybind: "mod+shift+backspace",
// TODO: Restore the command when the V2 client exposes session archive.
disabled: true,
hidden: serverSDK().protocolKind() !== "v1",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) void archiveSession(session)
@@ -1171,10 +1185,13 @@ export default function LegacyLayout(props: ParentProps) {
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await Promise.resolve(
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
project?.id ?? serverSDK().currentApi.project.current({ location: { directory: root } }),
)
.then((value) => (typeof value === "string" ? value : value.id))
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
.then(async (projectID) => {
await serverSDK().currentApi.projectCopy.refresh({ projectID, location: { directory: root } })
return serverSDK().currentApi.project.directories({ projectID, location: { directory: root } })
})
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
@@ -1192,10 +1209,10 @@ export default function LegacyLayout(props: ParentProps) {
.sync(target.id)
.then(() => sync.session.get(target.id))
.catch(() => undefined)
if (!resolved?.location.directory) return false
if (!canOpen(resolved.location.directory)) return false
setStore("lastProjectSession", root, { directory: resolved.location.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.location.directory)}/session/${resolved.id}`)
if (!resolved?.directory) return false
if (!canOpen(resolved.directory)) return false
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true
}
@@ -1211,7 +1228,7 @@ export default function LegacyLayout(props: ParentProps) {
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession({ directory: latest.location.directory, id: latest.id }))) {
if (latest && (await openSession(latest))) {
return
}
@@ -1219,7 +1236,7 @@ export default function LegacyLayout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await listAllSessions(serverSDK().api.session, {
session: await listAllSessions(serverSDK().currentApi.session, {
directory: item,
parentID: null,
order: "desc",
@@ -1228,16 +1245,16 @@ export default function LegacyLayout(props: ParentProps) {
),
Date.now(),
)
if (fetched && (await openSession({ directory: fetched.location.directory, id: fetched.id }))) {
if (fetched && (await openSession(fetched))) {
return
}
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
}
function navigateToSession(session: SessionInfo | undefined) {
function navigateToSession(session: Session | undefined) {
if (!session) return
navigateWithSidebarReset(`/${base64Encode(session.location.directory)}/session/${session.id}`)
navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
}
function openProject(directory: string, navigate = true) {
@@ -1283,7 +1300,13 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
// TODO: Restore project renames when the V2 client exposes a project update API.
const result = await serverSDK().legacy.project
.update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data)
if (!result) return
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
return
}
@@ -1379,7 +1402,7 @@ export default function LegacyLayout(props: ParentProps) {
const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id
const result = projectID
? await serverSDK()
.api.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
.currentApi.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
.then(() => true)
.catch((err) => {
showToast({
@@ -1437,7 +1460,9 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch(
() => [],
)
clearWorkspaceTerminals(
directory,
@@ -1445,8 +1470,16 @@ export default function LegacyLayout(props: ParentProps) {
platform,
serverSDK().scope,
)
// TODO: Restore workspace reset and instance disposal when V2 exposes these operations.
const result = false
const result = await serverSDK()
.legacy.workspace.reset(root, directory)
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.reset.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
if (!result) {
setBusy(directory, false)
@@ -1454,6 +1487,17 @@ export default function LegacyLayout(props: ParentProps) {
return
}
if ((await serverSDK().protocol) === "v1")
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.legacy.session.archive(session.id, session.directory)
.catch(() => undefined),
),
)
setBusy(directory, false)
dismiss()
@@ -1540,11 +1584,11 @@ export default function LegacyLayout(props: ParentProps) {
const [state, setState] = createStore({
status: "loading" as "loading" | "ready" | "error",
dirty: false,
sessions: [] as SessionInfo[],
sessions: [] as Session[],
})
const refresh = async () => {
const sessions = await listAllSessions(serverSDK().api.session, {
const sessions = await listAllSessions(serverSDK().currentApi.session, {
directory: props.directory,
order: "desc",
}).catch(() => [])
@@ -1786,7 +1830,7 @@ export default function LegacyLayout(props: ParentProps) {
clearSidebarHoverState()
const created = project.id
? await serverSDK()
.api.projectCopy.create({
.currentApi.projectCopy.create({
projectID: project.id,
strategy: "git_worktree",
directory: getDirectory(project.worktree),
@@ -1836,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
workspaceName,
renameWorkspace,
editorOpen,
@@ -1872,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
openSidebar: () => layout.sidebar.open(),
closeProject,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
canEditProject: () => serverSDK().protocolKind() === "v1",
toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds,
@@ -1882,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
},
}
@@ -1973,15 +2021,13 @@ export default function LegacyLayout(props: ParentProps) {
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<Show
when={!project.id || project.id === "global"}
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
>
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
void renameProject(project, next)
}}
onSave={(next) => void renameProject(project, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
@@ -2021,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
showEditProjectDialog(server.current!, project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={serverSDK().protocolKind() === "v1"}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
@@ -6,7 +6,7 @@ import {
parseDeepLink,
parseNewSessionDeepLink,
} from "./deep-links"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import {
childSessionOnPath,
closeHomeProject,
@@ -25,18 +25,16 @@ import { ServerConnection } from "@/context/server"
const serverKey = ServerConnection.Key.make
const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) =>
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
projectID: "project",
title: "",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
version: "v2",
parentID: undefined,
messageCount: 0,
permissions: { session: {}, share: {} },
time: { created: 0, updated: 0, archived: undefined },
...input,
location: { directory: input.directory },
directory: undefined,
}) as SessionInfo
}) as Session
describe("layout deep links", () => {
test("parses open-project deep links", () => {
+8 -8
View File
@@ -1,17 +1,17 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server"
import type { HomeProjectSelection } from "@/context/layout"
type SessionStore = {
session?: SessionInfo[]
session?: Session[]
path: { directory: string }
}
function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000
return (a: SessionInfo, b: SessionInfo) => {
return (a: Session, b: Session) => {
const aUpdated = a.time.updated ?? a.time.created
const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > oneMinuteAgo
@@ -23,8 +23,8 @@ function sortSessions(now: number) {
}
}
const isRootVisibleSession = (session: SessionInfo, directory: string) =>
pathKey(session.location.directory) === pathKey(directory) && !session.parentID && !session.time.archived
const isRootVisibleSession = (session: Session, directory: string) =>
pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
@@ -41,7 +41,7 @@ export function hasProjectPermissions<T>(
return Object.values(request ?? {}).some((list) => list?.some(include))
}
export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: string, activeID?: string) => {
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
if (!activeID || activeID === rootID) return
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
let id = activeID
@@ -102,13 +102,13 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
}
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: SessionInfo,
session: Session,
projects: T[],
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
) {
const direct = byID.get(session.projectID)
if (direct) return direct
const directory = pathKey(session.location.directory)
const directory = pathKey(session.directory)
return projects.find(
(project) =>
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+17 -21
View File
@@ -1,10 +1,11 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { Avatar } from "@opencode-ai/ui/avatar"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -14,7 +15,6 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionLabel } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
const hasPermissions = createMemo(() =>
dirs().some((directory) => {
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
if (serverSync().session.get(item.sessionID)?.directory !== directory) return false
return !permission.autoResponds(item, directory)
})
}),
@@ -74,9 +74,9 @@ export const ProjectIcon = (props: {
}
export type SessionItemProps = {
session: SessionInfo
list: SessionInfo[]
navList?: Accessor<SessionInfo[]>
session: Session
list: Session[]
navList?: Accessor<Session[]>
slug: string
mobile?: boolean
dense?: boolean
@@ -85,12 +85,13 @@ export type SessionItemProps = {
level?: number
sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
archiveSession: (session: SessionInfo) => Promise<void>
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
}
const SessionRow = (props: {
session: SessionInfo
session: Session
slug: string
mobile?: boolean
dense?: boolean
@@ -104,7 +105,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => sessionLabel(props.session)
const title = () => displayLabel(props.session)
return (
<A
@@ -152,14 +153,14 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const serverSync = useServerSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = serverSync().child(props.session.location.directory)
const [sessionStore] = serverSync().child(props.session.directory)
const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest(
sessionStore.session,
serverSync().session.data.permission,
props.session.id,
(item) => {
return !permission.autoResponds(item, props.session.location.directory)
return !permission.autoResponds(item, props.session.directory)
},
)
})
@@ -179,17 +180,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.()
const list = nav?.some(
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
)
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
? nav
: props.list
props.prefetchSession(props.session, priority)
const idx = list.findIndex(
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
)
const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
if (idx === -1) return
for (let step = 1; step <= span; step++) {
@@ -233,7 +230,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionLabel(props.session)}
value={displayLabel(props.session)}
gutter={10}
class="min-w-0 w-full"
>
@@ -245,8 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
<Show when={false}>
<Show when={!props.level && props.canArchive()}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
openSidebar: () => void
closeProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
onProjectFocus: (worktree: string) => void
navigateToProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
closeProject: (directory: string) => void
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={props.canEditProject()}>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
@@ -305,7 +309,7 @@ export const SortableProject = (props: {
const isWorking = createMemo(() =>
dirs().some((directory) => {
return Object.keys(serverSync().session.data.session_status).some((id) => {
if (serverSync().session.get(id)?.location.directory !== directory) return false
if (serverSync().session.get(id)?.directory !== directory) return false
return serverSync().session.data.session_working(id)
})
}),
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
onProjectFocus={props.ctx.onProjectFocus}
navigateToProject={props.ctx.navigateToProject}
showEditProjectDialog={props.ctx.showEditProjectDialog}
canEditProject={props.ctx.canEditProject}
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { type LocalProject } from "@/context/layout"
import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -36,12 +36,14 @@ type InlineEditorComponent = (props: {
export type WorkspaceSidebarContext = {
currentDir: Accessor<string>
navList: Accessor<SessionInfo[]>
navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
clearHoverProjectSoon: () => void
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
archiveSession: (session: SessionInfo) => Promise<void>
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
canResetWorkspace: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
workspaceValue: Accessor<string>
openEditor: WorkspaceSidebarContext["openEditor"]
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
@@ -199,13 +202,14 @@ const WorkspaceActions = (props: {
>
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
disabled
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.canResetWorkspace()}>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
@@ -243,7 +247,7 @@ const WorkspaceSessionList = (props: {
ctx: WorkspaceSidebarContext
showNew: Accessor<boolean>
loading: Accessor<boolean>
sessions: Accessor<SessionInfo[]>
sessions: Accessor<Session[]>
hasMore: Accessor<boolean>
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
@@ -273,6 +277,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/>
)}
</For>
@@ -417,6 +422,7 @@ export const SortableWorkspace = (props: {
workspaceValue={workspaceValue}
openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
canResetWorkspace={props.ctx.canResetWorkspace}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+27 -17
View File
@@ -1,5 +1,4 @@
import type { FilePart, Project, UserMessage } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@/types"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -53,7 +52,7 @@ import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
@@ -160,7 +159,7 @@ export function SessionPage() {
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const serverSync = useServerSync()
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.location.directory)
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
return (
// Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary.
@@ -253,7 +252,7 @@ function ResolvedTargetSessionRoute() {
() => params.id,
() => sync().session.lineage,
)
const directory = createMemo(() => current()?.session.location.directory)
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
@@ -362,6 +361,7 @@ export default function Page() {
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
@@ -719,13 +719,13 @@ export default function Page() {
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
return true
}
const loadReviewDiff = async (file: string, version?: number): Promise<FileDiffInfo | undefined> => {
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
const mode = vcsMode()
if (!mode) return
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
const directory = reviewDiffDirectory(root, file)
const source = reviewDiffs().find((diff) => diff.file === file)
const valid = (diff: FileDiffInfo | undefined) => {
const valid = (diff: VcsFileDiff | undefined) => {
if (!diff || !source) return
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
if (reviewDiffNeedsLoad(diff)) return
@@ -848,8 +848,11 @@ export default function Page() {
}
const gitMutation = useMutation(() => ({
// TODO: Restore Git initialization when the V2 client exposes this operation.
mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")),
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
@@ -894,17 +897,19 @@ export default function Page() {
() => {
const id = params.id
return [
protocol(),
sdk().directory,
id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
([serverProtocol, dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (serverProtocol !== "v1") return
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined)
@@ -1215,7 +1220,13 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
{/* TODO: Restore the init button when the V2 client exposes Git initialization. */}
<Show when={protocol() === "v1"}>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</Show>
</div>
)
@@ -1248,7 +1259,7 @@ export default function Page() {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (reviewMode() === "turn" && nogit()) {
// TODO: Restore SessionReviewEmptyNoGitV2 when the V2 client exposes Git initialization.
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
return empty(language.t("session.review.noVcs.createGit.description"))
}
return <SessionReviewEmptyChangesV2 />
@@ -1719,10 +1730,9 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
api: sdk().api.session,
api: sdk().currentApi.session,
sync: sync(),
serverSync: serverSync(),
session: () => sync().session.get(input.sessionID),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
}).catch((err) => {
@@ -1816,13 +1826,13 @@ export default function Page() {
const halt = (sessionID: string) =>
busy(sessionID)
? sdk()
.api.session.interrupt({ sessionID })
.currentApi.session.interrupt({ sessionID })
.catch(() => {})
: Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const session = sdk().api.session
const session = sdk().currentApi.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1845,7 +1855,7 @@ export default function Page() {
const sessionID = params.id
if (!sessionID) return
const session = sdk().api.session
const session = sdk().currentApi.session
const target = sync()
const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
}) as Session
const permission = (id: string, sessionID: string) =>
({
@@ -1,7 +1,6 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, Todo } from "@/types"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
@@ -1,5 +1,5 @@
import { For, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest } from "@/types"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
@@ -13,7 +13,7 @@ export function SessionPermissionDock(props: {
const language = useLanguage()
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.action}.description`
const key = `settings.permissions.tool.${props.request.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
@@ -59,11 +59,11 @@ export function SessionPermissionDock(props: {
</div>
</Show>
<Show when={props.request.resources.length > 0}>
<Show when={props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.request.resources}>
<For each={props.request.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
import type { QuestionAnswer, QuestionRequest } from "@/types"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -1,8 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
function sessionTreeRequest<T>(
session: SessionInfo[],
session: Session[],
request: Record<string, T[] | undefined>,
sessionID?: string,
include: (item: T) => boolean = () => true,
@@ -35,7 +34,7 @@ function sessionTreeRequest<T>(
}
export function sessionPermissionRequest(
session: SessionInfo[],
session: Session[],
request: Record<string, PermissionRequest[] | undefined>,
sessionID?: string,
include?: (item: PermissionRequest) => boolean,
@@ -44,7 +43,7 @@ export function sessionPermissionRequest(
}
export function sessionQuestionRequest(
session: SessionInfo[],
session: Session[],
request: Record<string, QuestionRequest[] | undefined>,
sessionID?: string,
include?: (item: QuestionRequest) => boolean,
@@ -1,5 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -54,8 +55,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const readFile = async (path: string) => {
return sdk()
.api.file.read({ path, location: { directory: sdk().directory } })
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
.currentApi.file.read({ path, location: { directory: sdk().directory } })
.then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) }))
.catch((error) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
@@ -23,6 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -56,8 +57,8 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,12 +296,14 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = (): string | undefined => undefined
// TODO: Restore these actions when the V2 client exposes session sharing.
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const shareEnabled = () => false
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -313,7 +315,10 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
@@ -331,7 +336,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -661,16 +666,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
// TODO: Restore sharing when the V2 client exposes a session sharing API.
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -678,7 +681,7 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
sdk().currentApi.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -820,8 +823,8 @@ export function MessageTimeline(props: {
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
// TODO: Restore archiving when the V2 client exposes a session archive API.
await Promise.reject(new Error("Session archiving is unavailable"))
await sdk()
.legacy.session.archive(sessionID, sdk().directory)
.then(() => {
sync().set(
produce((draft) => {
@@ -850,7 +853,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.currentApi.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
@@ -914,9 +917,10 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -1569,7 +1573,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
{/* TODO: Restore archive when the V2 client exposes session archive. */}
<Show when={protocol() === "v1"}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1638,7 +1646,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
{/* TODO: Restore archive when the V2 client exposes session archive. */}
<Show when={protocol() === "v1"}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...

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