mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 22:23:18 -04:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0672a8c146 | |||
| ec23ea1564 | |||
| e91951785c | |||
| 214b12bbe3 | |||
| c0718059b7 | |||
| faf1029723 | |||
| 97d3cd0b3a | |||
| 7fd1eee35a | |||
| 2eecf076c4 | |||
| ed08f0e691 | |||
| 238e1903df | |||
| 77c7a7def7 | |||
| 88788941df | |||
| d633d794c2 | |||
| 08d52be8c2 | |||
| 79d5436d2a | |||
| 9a4bd2ba16 | |||
| e68144cb67 | |||
| e5da5bfab2 | |||
| 3a1fb5ae65 | |||
| b3d6063329 | |||
| 6c3c4bc50f | |||
| 7b349654e3 | |||
| 3d2652d7b9 | |||
| 1dea4b9391 | |||
| 15864304a5 | |||
| e312d261a8 | |||
| 2a83911c7e | |||
| 0eaa04718c | |||
| 2e5ec616d2 | |||
| b2551b4e5d | |||
| e81450809d | |||
| 2524e6be8b | |||
| b58f29a4ef | |||
| 8fec7e0e91 | |||
| 94f9d32040 | |||
| ea3e0dde19 | |||
| b731b11184 | |||
| 8676dcf705 | |||
| 2636797c65 | |||
| e673807e39 | |||
| 9a3a1732f1 | |||
| e03a147b71 | |||
| e461fdc2d0 | |||
| e4178886fa | |||
| 1e6bfaf3d7 | |||
| 876a4a2586 | |||
| 5e77c494c7 | |||
| 9be9dd737c | |||
| 4d22d4e75f | |||
| 8b93bc395d | |||
| e756e497c2 | |||
| 0d2684b673 | |||
| 858caa6848 | |||
| 9a89851cea | |||
| 876459788f | |||
| b0ab1e2992 | |||
| d158f2cd39 | |||
| 9be3aa92b5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
|
||||
@@ -380,6 +380,8 @@
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
|
||||
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
|
||||
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
|
||||
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
|
||||
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
|
||||
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
|
||||
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
|
||||
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"translate:app": "bun run script/translate-app.ts",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
|
||||
@@ -374,16 +374,18 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ type: "tool" as const, name }),
|
||||
})
|
||||
|
||||
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
|
||||
type: "tool_use",
|
||||
id: part.id,
|
||||
id: scrubToolCallID(part.id),
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
})
|
||||
|
||||
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
|
||||
type: "server_tool_use",
|
||||
id: part.id,
|
||||
id: scrubToolCallID(part.id),
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
})
|
||||
@@ -405,7 +407,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
|
||||
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||
@@ -587,7 +589,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.id,
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
content: yield* lowerToolResultContent(part),
|
||||
is_error: part.result.type === "error" ? true : undefined,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
|
||||
@@ -37,6 +37,10 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||
}
|
||||
|
||||
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -284,8 +288,16 @@ const lowerToolCall = (part: ToolCallPart) => ({
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
const flushMedia = () => {
|
||||
if (!pendingMedia) return
|
||||
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
|
||||
pendingMedia = undefined
|
||||
}
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role !== "tool") flushMedia()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
||||
const previous = contents.at(-1)
|
||||
@@ -367,6 +379,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const value = ProviderShared.normalizeToolFile(item)
|
||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||
}
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
@@ -375,13 +388,19 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
name: part.name,
|
||||
content: text.join("\n"),
|
||||
},
|
||||
parts: media.length > 0 ? media : undefined,
|
||||
parts: legacyToolMedia || media.length === 0 ? undefined : media,
|
||||
},
|
||||
})
|
||||
}
|
||||
contents.push({ role: "user", parts })
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
flushMedia()
|
||||
return contents
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Options {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly rotateAfterMs?: number
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly driver?: (input: {
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
@@ -147,18 +149,19 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* HttpTransport.jsonRequestParts(input)
|
||||
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
|
||||
const channel = input.webSocket
|
||||
? yield* Effect.gen(function* () {
|
||||
const create = yield* message(parts.jsonBody)
|
||||
const base = driver(options, create.message)
|
||||
return {
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
const channel =
|
||||
input.webSocket && (options.enabled?.(parts.url) ?? true)
|
||||
? yield* Effect.gen(function* () {
|
||||
const create = yield* message(parts.jsonBody)
|
||||
const base = driver(options, create.message)
|
||||
return {
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
http: {
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
|
||||
@@ -11,7 +11,7 @@ import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
@@ -247,12 +247,16 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
export const channelTransport = (options: Omit<Options, "driver">) =>
|
||||
OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
...options,
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
|
||||
})
|
||||
export const transport = channelTransport({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
|
||||
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
|
||||
@@ -74,6 +74,7 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -127,6 +128,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
@@ -10,6 +11,7 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
|
||||
export const id = ProviderID.make("azure")
|
||||
const routeAuth = Auth.remove("authorization")
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
|
||||
// Azure needs the customer's resource URL; supply either `resourceName`
|
||||
// (helper builds the URL) or `baseURL` directly.
|
||||
@@ -40,6 +42,30 @@ const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "azure-openai-responses",
|
||||
name: "Azure OpenAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
enabled: (value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname.endsWith(".openai.azure.com") &&
|
||||
url.pathname.endsWith("/openai/v1/responses") &&
|
||||
url.searchParams.get("api-version") === "v1"
|
||||
)
|
||||
},
|
||||
url: (value) => {
|
||||
const url = new URL(value)
|
||||
url.searchParams.delete("api-version")
|
||||
return url.toString()
|
||||
},
|
||||
headers: (headers) => {
|
||||
const apiKey = headers["api-key"]
|
||||
if (!apiKey) return headers
|
||||
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
|
||||
@@ -13,6 +13,7 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
|
||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
const HEADER_VERSION = "2023-06-01" as const
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -57,6 +58,7 @@ const route = Route.make({
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: AnthropicMessages.framing,
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -28,13 +28,19 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
}),
|
||||
defaults: { providerOptions: { store: false } },
|
||||
})
|
||||
|
||||
|
||||
@@ -82,6 +82,14 @@ describe("provider error classification", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies network error text as provider internal", () => {
|
||||
expect(
|
||||
["network error", "network-error", "network_error"].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
expect(
|
||||
[
|
||||
|
||||
@@ -327,6 +327,29 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scrubs outbound tool call IDs without truncating them", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = `functions.lookup:1|${"x".repeat(64)}`
|
||||
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
|
||||
Message.tool({ id, name: "lookup", result: "done" }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
|
||||
])
|
||||
expect(scrubbed.length).toBeGreaterThan(64)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches parallel tool results into one Anthropic user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1393,14 +1416,14 @@ describe("Anthropic Messages route", () => {
|
||||
Message.assistant([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "srvtoolu_abc",
|
||||
id: "srvtoolu.abc",
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "srvtoolu_abc",
|
||||
id: "srvtoolu.abc",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
providerExecuted: true,
|
||||
|
||||
@@ -181,6 +181,53 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one function-response turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "noon" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -281,14 +328,18 @@ describe("Gemini route", () => {
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
|
||||
}),
|
||||
@@ -321,11 +372,161 @@ describe("Gemini route", () => {
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "" },
|
||||
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("nests media inside function responses for gemini 3", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
input: { path: "pixel.png" },
|
||||
providerMetadata: { google: { thoughtSignature: "sig_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flushes pending media before system update text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "shot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
Message.system("Update."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
|
||||
{ text: "<system-update>\nUpdate.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("collects legacy tool media into one turn after merged responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
|
||||
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "shot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_2",
|
||||
name: "shot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "text", text: "no image here" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
|
||||
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "Attached media from tool result:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Google Vertex providers", () => {
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(request.headers.get("anthropic-version")).toBeNull()
|
||||
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
|
||||
const body = yield* Effect.promise(() => request.json())
|
||||
expect(body).toMatchObject({
|
||||
anthropic_version: "vertex-2023-10-16",
|
||||
|
||||
@@ -691,6 +691,134 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
|
||||
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
|
||||
expect(exchange.connect.headers.authorization).toBe("Bearer test")
|
||||
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||
type: "response.create",
|
||||
model: "grok-4.5",
|
||||
store: false,
|
||||
})
|
||||
return {
|
||||
frames: Stream.make(
|
||||
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const cases = [
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
|
||||
authorization: "Bearer azure-key",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
|
||||
"deployment",
|
||||
),
|
||||
authorization: "Bearer entra-token",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
|
||||
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
|
||||
expect(exchange.connect.headers.authorization).toBe(item.authorization)
|
||||
expect(exchange.connect.headers["api-key"]).toBeUndefined()
|
||||
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||
type: "response.create",
|
||||
model: "deployment",
|
||||
store: false,
|
||||
})
|
||||
return {
|
||||
frames: Stream.make(
|
||||
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
{
|
||||
model: Azure.configure({
|
||||
resourceName: "opencode-test",
|
||||
apiKey: "azure-key",
|
||||
apiVersion: "2025-04-01-preview",
|
||||
}).responses("deployment"),
|
||||
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({
|
||||
resourceName: "opencode-test",
|
||||
apiKey: "azure-key",
|
||||
useDeploymentBasedUrls: true,
|
||||
}).responses("deployment"),
|
||||
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
|
||||
"deployment",
|
||||
),
|
||||
url: "https://gateway.example/azure/responses",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(input.request.url).toBe(item.url)
|
||||
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { installSseTransport } from "../../utils/sse-transport"
|
||||
import { expectSessionReady } from "../../utils/waits"
|
||||
@@ -254,7 +255,7 @@ function describeEvent(event: OpenCodeEvent) {
|
||||
|
||||
export function event(
|
||||
type: "session.status",
|
||||
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
|
||||
data: Wire<Extract<OpenCodeEvent, { type: "session.status" }>["data"]>,
|
||||
): OpenCodeEvent {
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
@@ -465,7 +466,7 @@ export function userMessage(
|
||||
): SessionMessageUser {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
return {
|
||||
return wire<SessionMessageUser>({
|
||||
id,
|
||||
type: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
@@ -499,7 +500,7 @@ export function userMessage(
|
||||
]
|
||||
}),
|
||||
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
@@ -519,7 +520,7 @@ export function assistantMessage(
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
const content = parts.map((part) => messageContent(part, id, ordinals))
|
||||
nextOrdinals.set(id, ordinals)
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
id,
|
||||
type: "assistant",
|
||||
metadata: { parentID: input.parentID ?? userID },
|
||||
@@ -531,7 +532,7 @@ export function assistantMessage(
|
||||
tokens,
|
||||
...(input.completed === false ? {} : { finish: "stop" as const }),
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
|
||||
@@ -643,8 +644,8 @@ export function project() {
|
||||
}
|
||||
}
|
||||
|
||||
export function session(input: Partial<Session> = {}): Session {
|
||||
return {
|
||||
export function session(input: Partial<Wire<Session>> = {}): Session {
|
||||
return wire<Session>({
|
||||
id: sessionID,
|
||||
projectID,
|
||||
location: { directory },
|
||||
@@ -653,7 +654,7 @@ export function session(input: Partial<Session> = {}): Session {
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
...input,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageContent(
|
||||
@@ -829,7 +830,7 @@ function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef
|
||||
|
||||
function makeEvent<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
data: Wire<Extract<OpenCodeEvent, { type: Type }>["data"]>,
|
||||
): OpenCodeEvent {
|
||||
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
|
||||
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
@@ -12,16 +13,19 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||
const userID = "msg_parent_hydration_user"
|
||||
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
|
||||
const user = {
|
||||
const userSeed = fixture.messages[fixture.targetID][0]
|
||||
if (userSeed?.type !== "user") throw new Error("Expected the first target fixture message to be a user message")
|
||||
const user = wire<SessionMessageUser>({
|
||||
...userSeed,
|
||||
id: userID,
|
||||
time: { created: 1700001000000 },
|
||||
} satisfies SessionMessageInfo
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
|
||||
})
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3]
|
||||
if (assistantSeed?.type !== "assistant")
|
||||
throw new Error("Expected the fourth target fixture message to be an assistant message")
|
||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
...assistantSeed,
|
||||
id: messageID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
@@ -30,7 +34,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
|
||||
: part,
|
||||
),
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
})
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||
import { expect } from "../benchmark"
|
||||
@@ -17,13 +24,16 @@ const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = OpenCodeEvent
|
||||
type TextStartedEvent = Extract<OpenCodeEvent, { type: "session.text.started" }>
|
||||
type TextDeltaEvent = Extract<OpenCodeEvent, { type: "session.text.delta" }>
|
||||
type TimelineEventSeed = Pick<Wire<TextStartedEvent>, "type" | "data"> | Pick<Wire<TextDeltaEvent>, "type" | "data">
|
||||
|
||||
const userMessage = {
|
||||
const userMessage = wire<SessionMessageUser>({
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
const editPart: ToolSeed = {
|
||||
id: editPartID,
|
||||
@@ -39,20 +49,14 @@ const editPart: ToolSeed = {
|
||||
content: [{ type: "text", text: "Edited src/regression.ts" }],
|
||||
metadata: {
|
||||
files: [
|
||||
currentFile(
|
||||
"src/regression.ts",
|
||||
"export const value = 'before'\n",
|
||||
"export const value = 'after'\n",
|
||||
1,
|
||||
1,
|
||||
),
|
||||
currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1),
|
||||
],
|
||||
},
|
||||
},
|
||||
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
const assistantMessage = wire<SessionMessageAssistant>({
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
@@ -61,7 +65,7 @@ const assistantMessage = {
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
export async function setupTimelineBenchmark(
|
||||
page: Page,
|
||||
@@ -155,23 +159,29 @@ export async function setupTimelineBenchmark(
|
||||
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
|
||||
return [
|
||||
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
timelineEvent({ type: "session.text.started", data: { sessionID, assistantMessageID, ordinal: 0 } }),
|
||||
timelineEvent({
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||
return Array.from({ length: deltaCount }, (_, index) =>
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
timelineEvent({
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -182,7 +192,7 @@ function performanceTurn(index: number) {
|
||||
const assistantID = `msg_0000_${suffix}_b_assistant`
|
||||
const before = historicalSource(index, false)
|
||||
const after = historicalSource(index, true)
|
||||
const parts = [
|
||||
const parts: ContentSeed[] = [
|
||||
...(index % 5 === 0
|
||||
? [
|
||||
{
|
||||
@@ -192,7 +202,7 @@ function performanceTurn(index: number) {
|
||||
type: "reasoning",
|
||||
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
||||
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
@@ -201,7 +211,7 @@ function performanceTurn(index: number) {
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: historicalMarkdown(index),
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
...(index % 8 === 0
|
||||
? [
|
||||
{
|
||||
@@ -221,7 +231,7 @@ function performanceTurn(index: number) {
|
||||
ran: 1690000001200 + index * 2_000,
|
||||
completed: 1690000001400 + index * 2_000,
|
||||
},
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
...(index % 12 === 0
|
||||
@@ -241,7 +251,7 @@ function performanceTurn(index: number) {
|
||||
ran: 1690000001400 + index * 2_000,
|
||||
completed: 1690000001500 + index * 2_000,
|
||||
},
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
...(index % 16 === 0
|
||||
@@ -267,11 +277,11 @@ function performanceTurn(index: number) {
|
||||
ran: 1690000001500 + index * 2_000,
|
||||
completed: 1690000001700 + index * 2_000,
|
||||
},
|
||||
},
|
||||
} satisfies ContentSeed,
|
||||
]
|
||||
: []),
|
||||
] as unknown as ContentSeed[]
|
||||
return [
|
||||
]
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
@@ -298,7 +308,7 @@ function performanceTurn(index: number) {
|
||||
return toolContent(part)
|
||||
}),
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
}
|
||||
|
||||
type ToolSeed = {
|
||||
@@ -338,20 +348,22 @@ function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number]
|
||||
|
||||
let eventSequence = 0
|
||||
|
||||
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
durable = false,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
function timelineEvent(seed: TimelineEventSeed): TextStartedEvent | TextDeltaEvent {
|
||||
eventSequence++
|
||||
return {
|
||||
if (seed.type === "session.text.started")
|
||||
return wire<TextStartedEvent>({
|
||||
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
...seed,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version: 1 },
|
||||
})
|
||||
return wire<TextDeltaEvent>({
|
||||
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
...seed,
|
||||
location: { directory },
|
||||
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
})
|
||||
}
|
||||
|
||||
function historicalMarkdown(index: number) {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { wire } from "@/test-fixture"
|
||||
import type {
|
||||
JsonValue,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
const words = [
|
||||
"alpha",
|
||||
@@ -64,13 +71,13 @@ function id(prefix: string, value: number) {
|
||||
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
return wire<SessionMessageUser>({
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
@@ -80,7 +87,7 @@ function assistantMessage(
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
return wire<SessionMessageAssistant>({
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
@@ -90,7 +97,7 @@ function assistantMessage(
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
@@ -151,10 +158,7 @@ function toolPart(
|
||||
metadataOverride ??
|
||||
(tool === "patch"
|
||||
? {
|
||||
files: [
|
||||
patchFile(index, "modified"),
|
||||
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
|
||||
],
|
||||
files: [patchFile(index, "modified"), patchFile(index + 1, index % 2 === 0 ? "added" : "deleted")],
|
||||
}
|
||||
: tool === "edit" || tool === "write"
|
||||
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
|
||||
@@ -232,14 +236,20 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0
|
||||
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
3,
|
||||
"edit",
|
||||
{ path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" },
|
||||
700,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
|
||||
: []),
|
||||
...(index % 8 === 0 ? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] : []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
@@ -389,4 +399,3 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
cursor: start > 0 ? messages[start].id : undefined,
|
||||
}
|
||||
}
|
||||
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -5,9 +5,11 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
addInitScript: () => Promise.resolve(),
|
||||
on: () => page,
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
@@ -21,6 +23,7 @@ test("applies message latency after a list response gate is released", async ()
|
||||
messageDelay: 25,
|
||||
beforeMessagesResponse: () => {
|
||||
events.push("before")
|
||||
started.resolve()
|
||||
return gate.promise
|
||||
},
|
||||
onMessages: (request) => events.push(request.phase),
|
||||
@@ -31,12 +34,18 @@ test("applies message latency after a list response gate is released", async ()
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
|
||||
request: () => ({
|
||||
url: () => "http://127.0.0.1:4096/api/session/session/message",
|
||||
method: () => "GET",
|
||||
headers: () => ({}),
|
||||
postDataBuffer: () => null,
|
||||
}),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Route)
|
||||
await started.promise
|
||||
expect(events).toEqual(["start", "before"])
|
||||
|
||||
const released = performance.now()
|
||||
@@ -45,3 +54,42 @@ test("applies message latency after a list response gate is released", async ()
|
||||
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
||||
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
||||
})
|
||||
|
||||
test("routes requests through the HttpApi contract", async () => {
|
||||
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
addInitScript: () => Promise.resolve(),
|
||||
on: () => page,
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Page
|
||||
await mockOpenCodeServer(page, {
|
||||
provider: {},
|
||||
directory: "C:/OpenCode",
|
||||
project: {},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onConnectKey: connected.resolve,
|
||||
})
|
||||
|
||||
const body = Buffer.from(JSON.stringify({ key: "secret" }))
|
||||
let status: number | undefined
|
||||
await handler!({
|
||||
request: () => ({
|
||||
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
|
||||
method: () => "POST",
|
||||
headers: () => ({ "content-type": "application/json" }),
|
||||
postDataBuffer: () => body,
|
||||
}),
|
||||
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
|
||||
status = response?.status
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Route)
|
||||
|
||||
expect(status).toBe(204)
|
||||
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
if (path) return []
|
||||
return [
|
||||
{
|
||||
name: "frontend",
|
||||
name: "",
|
||||
path: "frontend\\",
|
||||
absolute: `${directory}/frontend`,
|
||||
type: "directory" as const,
|
||||
@@ -116,6 +116,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
|
||||
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||
await expect(frontendRow).toBeVisible()
|
||||
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||
await frontendRow.click()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
@@ -22,7 +24,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
|
||||
const dialog = page.locator(".settings-v2-dialog")
|
||||
const dialog = page.locator(".settings-dialog")
|
||||
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
await expect(autoAccept).toBeVisible()
|
||||
@@ -63,7 +65,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
await expect
|
||||
@@ -81,7 +83,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
const backgroundPermission: Wire<OpenCodeEvent> = {
|
||||
id: "evt_permission_background_a",
|
||||
created: 1700000001000,
|
||||
type: "permission.asked",
|
||||
@@ -94,7 +96,8 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(backgroundPermission))
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
@@ -108,7 +111,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
},
|
||||
])
|
||||
|
||||
await transport.send({
|
||||
const childBackgroundPermission: Wire<OpenCodeEvent> = {
|
||||
id: "evt_permission_background_a_child",
|
||||
created: 1700000002000,
|
||||
type: "permission.asked",
|
||||
@@ -121,7 +124,8 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(childBackgroundPermission))
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -108,14 +110,14 @@ async function openReview(page: Page) {
|
||||
return []
|
||||
},
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
items: wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_review_image_flash_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -121,14 +123,14 @@ async function openReview(page: Page) {
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
items: wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: "msg_review_line_comment_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,7 +9,7 @@ const directory = "C:/OpenCode/SessionMessageRevert"
|
||||
const projectID = "proj_session_message_revert"
|
||||
const sessionID = "ses_session_message_revert"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = [
|
||||
const messages = wire<SessionMessageInfo[]>([
|
||||
{ id: "msg_first", type: "user", text: "First prompt", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_first_reply",
|
||||
@@ -19,7 +20,7 @@ const messages = [
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
])
|
||||
|
||||
test("reverts directly to the selected user message", async ({ page }) => {
|
||||
const staged: { sessionID: string; messageID: string }[] = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -138,7 +140,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
}),
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
const created: Wire<OpenCodeEvent> = {
|
||||
id: "evt_form_created",
|
||||
created: 1700000001000,
|
||||
type: "form.created",
|
||||
@@ -161,18 +163,20 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(created))
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
const cancelled: Wire<OpenCodeEvent> = {
|
||||
id: "evt_form_cancelled",
|
||||
created: 1700000002000,
|
||||
type: "form.cancelled",
|
||||
location: { directory },
|
||||
data: { sessionID, id: "frm_question_caret" },
|
||||
})
|
||||
}
|
||||
await transport.send(wire<OpenCodeEvent>(cancelled))
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
await page.keyboard.press("x")
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
@@ -25,12 +32,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
const userMessage = wire<SessionMessageUser>({
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
const editPart = {
|
||||
id: editPartID,
|
||||
@@ -72,7 +79,7 @@ const streamedTextPart = {
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
const assistantMessage = wire<SessionMessageAssistant>({
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
@@ -81,7 +88,7 @@ const assistantMessage = {
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
|
||||
test.describe("regression: session timeline local row state", () => {
|
||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||
@@ -330,56 +337,61 @@ let eventSequence = -1
|
||||
|
||||
function textEvents(): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
|
||||
eventValue(
|
||||
"session.text.ended",
|
||||
{
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.text.started",
|
||||
data: { sessionID, assistantMessageID, ordinal: 0 },
|
||||
})),
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.text.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: streamedTextPart.text,
|
||||
},
|
||||
1,
|
||||
),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.input.started",
|
||||
{
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.input.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.input.ended",
|
||||
{
|
||||
})),
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.input.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.called",
|
||||
{
|
||||
})),
|
||||
eventValue(1, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.called",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: true,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
})),
|
||||
eventValue(2, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
@@ -387,25 +399,30 @@ function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
type EventEnvelope<Version extends 1 | 2> = {
|
||||
id: string
|
||||
created: number
|
||||
location: { directory: string }
|
||||
durable: { aggregateID: string; seq: number; version: Version }
|
||||
}
|
||||
|
||||
function eventValue<Version extends 1 | 2>(
|
||||
version: Version,
|
||||
build: (envelope: EventEnvelope<Version>) => Wire<OpenCodeEvent>,
|
||||
): OpenCodeEvent {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_collapse_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
return wire<OpenCodeEvent>(
|
||||
build({
|
||||
id: `evt_collapse_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
@@ -209,13 +210,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
const content: SessionMessageAssistant["content"] = target
|
||||
? [
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
|
||||
toolContent(
|
||||
@@ -231,7 +226,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
{ type: "text", text: "This assistant text is immediately after the explored context group." },
|
||||
]
|
||||
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
|
||||
return [
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
@@ -249,7 +244,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
finish: "stop",
|
||||
content,
|
||||
},
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
function contextTool(
|
||||
@@ -314,9 +309,10 @@ let eventSequence = -1
|
||||
|
||||
function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
eventValue(2, (envelope) => ({
|
||||
...envelope,
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
@@ -324,25 +320,30 @@ function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||
metadata: part.state.metadata,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
type EventEnvelope<Version extends 1 | 2> = {
|
||||
id: string
|
||||
created: number
|
||||
location: { directory: string }
|
||||
durable: { aggregateID: string; seq: number; version: Version }
|
||||
}
|
||||
|
||||
function eventValue<Version extends 1 | 2>(
|
||||
version: Version,
|
||||
build: (envelope: EventEnvelope<Version>) => Wire<OpenCodeEvent>,
|
||||
): OpenCodeEvent {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_context_resize_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
return wire<OpenCodeEvent>(
|
||||
build({
|
||||
id: `evt_context_resize_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { wire } from "@/test-fixture"
|
||||
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
const user = wire<SessionMessageUser>({ id: "msg_user", type: "user", text: "Run it", time: { created: 1 } })
|
||||
|
||||
const assistant = (
|
||||
completed: boolean,
|
||||
tool = false,
|
||||
childID?: string,
|
||||
background = false,
|
||||
): SessionMessageAssistant => ({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Inspect code", ...(background ? { background: true } : {}) },
|
||||
metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) },
|
||||
const assistant = (completed: boolean, tool = false, childID?: string, background = false): SessionMessageAssistant =>
|
||||
wire<SessionMessageAssistant>({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Inspect code", ...(background ? { background: true } : {}) },
|
||||
metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) },
|
||||
},
|
||||
time: { created: 2 },
|
||||
},
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
})
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
})
|
||||
|
||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
const ownerWarnings: string[] = []
|
||||
@@ -39,7 +42,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
sessionMessages: wire<SessionMessageInfo[]>([
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
assistant(true),
|
||||
@@ -59,7 +62,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
time: { created: 5 },
|
||||
},
|
||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
|
||||
],
|
||||
]),
|
||||
})
|
||||
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
@@ -97,10 +100,10 @@ test("waits for completion before labeling requested background work", async ({
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
const childID = Session.ID.make("ses_running_child")
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessions: [session(), session({ id: childID, parentID: Session.ID.make(sessionID), title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
@@ -111,10 +114,10 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
})
|
||||
|
||||
test("shows a badge for active background work", async ({ page }) => {
|
||||
const childID = "ses_background_child"
|
||||
const childID = Session.ID.make("ses_background_child")
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
||||
sessions: [session(), session({ id: childID, parentID: Session.ID.make(sessionID) })],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
@@ -122,10 +125,10 @@ test("shows a badge for active background work", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
const backgroundID = Session.ID.make("ses_background_existing")
|
||||
const blockingID = Session.ID.make("ses_background_blocking")
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
sessionMessages: wire<SessionMessageInfo[]>([
|
||||
user,
|
||||
{
|
||||
id: "msg_backgrounded",
|
||||
@@ -180,11 +183,11 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
],
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
|
||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
|
||||
session({ id: backgroundID, parentID: Session.ID.make(sessionID), title: "Background task" }),
|
||||
session({ id: blockingID, parentID: Session.ID.make(sessionID), title: "Foreground task" }),
|
||||
],
|
||||
sessionStatus: {
|
||||
[sessionID]: { type: "busy" },
|
||||
@@ -203,12 +206,15 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||
).toHaveAttribute("data-active", "true")
|
||||
|
||||
await timeline.transport.send({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: backgroundID },
|
||||
} as never)
|
||||
await timeline.transport.send(
|
||||
wire<OpenCodeEvent>({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: backgroundID },
|
||||
durable: { aggregateID: backgroundID, seq: 0, version: 1 },
|
||||
}),
|
||||
)
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -56,14 +57,15 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
events.push({
|
||||
const deleted: Wire<OpenCodeEvent> = {
|
||||
id: "evt_session_deleted",
|
||||
created: 1700000003000,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: childID, seq: 1, version: 2 },
|
||||
location: { directory },
|
||||
data: { sessionID: childID },
|
||||
})
|
||||
}
|
||||
events.push(wire<OpenCodeEvent>(deleted))
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
@@ -148,7 +150,7 @@ function childSession() {
|
||||
function parentMessages(): SessionMessageInfo[] {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
return [
|
||||
return wire<SessionMessageInfo[]>([
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
@@ -179,7 +181,7 @@ function parentMessages(): SessionMessageInfo[] {
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const words = [
|
||||
"alpha",
|
||||
@@ -64,13 +65,13 @@ function id(prefix: string, value: number) {
|
||||
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
return wire<SessionMessageInfo>({
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
@@ -80,7 +81,7 @@ function assistantMessage(
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
return wire<SessionMessageInfo>({
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
@@ -90,7 +91,7 @@ function assistantMessage(
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
@@ -140,10 +141,7 @@ function toolPart(
|
||||
const metadata =
|
||||
tool === "patch"
|
||||
? {
|
||||
files: [
|
||||
patchFile(index, "modified"),
|
||||
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
|
||||
],
|
||||
files: [patchFile(index, "modified"), patchFile(index + 1, index % 2 === 0 ? "added" : "deleted")],
|
||||
}
|
||||
: tool === "edit" || tool === "write"
|
||||
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
|
||||
@@ -214,14 +212,20 @@ function turn(index: number): SessionMessageInfo[] {
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0
|
||||
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
3,
|
||||
"edit",
|
||||
{ path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" },
|
||||
700,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
|
||||
: []),
|
||||
...(index % 8 === 0 ? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] : []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
|
||||
@@ -144,7 +144,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => String(message.id))
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
@@ -284,7 +284,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => String(message.id))
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
const ids = new Set(destination)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
|
||||
const Json = Schema.Json.pipe(
|
||||
Schema.decodeTo(Schema.Unknown, {
|
||||
decode: SchemaGetter.passthrough(),
|
||||
encode: SchemaGetter.transform(jsonValue),
|
||||
}),
|
||||
HttpApiSchema.asJson(),
|
||||
)
|
||||
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
|
||||
const Query = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
parentID: Schema.optional(Schema.String),
|
||||
search: Schema.optional(Schema.String),
|
||||
order: Schema.optional(Schema.String),
|
||||
cursor: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
path: Schema.optional(Schema.String),
|
||||
query: Schema.optional(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
})
|
||||
const SessionParams = { sessionID: Schema.String }
|
||||
const NoContent = HttpApiSchema.NoContent
|
||||
|
||||
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("event", "/api/event", {
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
|
||||
params: { integrationID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
|
||||
params: { integrationID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
|
||||
params: { projectID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
|
||||
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
|
||||
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
|
||||
params: { ptyID: Schema.String },
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionList", "/api/session", {
|
||||
query: Query,
|
||||
success: Json,
|
||||
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
|
||||
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
|
||||
params: { ...SessionParams, formID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
|
||||
params: { ...SessionParams, formID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
|
||||
params: SessionParams,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
|
||||
params: { ...SessionParams, permissionID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
|
||||
params: SessionParams,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
|
||||
params: { ...SessionParams, messageID: Schema.String },
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
|
||||
params: SessionParams,
|
||||
query: Query,
|
||||
success: Json,
|
||||
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
)
|
||||
|
||||
export const MockApi = HttpApi.make("mock").add(Group)
|
||||
|
||||
function jsonValue(value: unknown): Schema.Json {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (Array.isArray(value)) return value.map(jsonValue)
|
||||
if (!value || typeof value !== "object") return null
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { Duration, Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown | (() => unknown)
|
||||
@@ -39,9 +43,8 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
let nextCursor = 0
|
||||
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
@@ -128,316 +131,331 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const transport = HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, state)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
|
||||
config.eventRetry,
|
||||
)
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
return route.fulfill({ status: 204, headers: corsHeaders })
|
||||
}
|
||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: {
|
||||
|
||||
const body = route.request().postDataBuffer()
|
||||
const response = await transport.handler(
|
||||
new Request(url, {
|
||||
method: route.request().method(),
|
||||
headers: route.request().headers(),
|
||||
body: body ? Uint8Array.from(body) : undefined,
|
||||
}),
|
||||
)
|
||||
if (response.status === 404 && url.origin !== server) return route.fallback()
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
}
|
||||
|
||||
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
|
||||
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
|
||||
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
|
||||
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
|
||||
handlers
|
||||
.handleRaw("event", () => {
|
||||
const events = config.events?.()
|
||||
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
|
||||
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
|
||||
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
||||
.join("")
|
||||
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
|
||||
})
|
||||
.handleRaw("fsRead", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
|
||||
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
|
||||
const content =
|
||||
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
|
||||
}),
|
||||
)
|
||||
.handleAll({
|
||||
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
},
|
||||
},
|
||||
data: [],
|
||||
}),
|
||||
agent: () =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
|
||||
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
|
||||
modelDefault: () =>
|
||||
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
|
||||
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
integrationGet: (ctx) =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: {
|
||||
id: ctx.params.integrationID,
|
||||
name: ctx.params.integrationID,
|
||||
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
}),
|
||||
integrationConnect: (ctx) =>
|
||||
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
|
||||
Effect.andThen(noContent),
|
||||
),
|
||||
credentialRemove: () => noContent,
|
||||
command: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
skill: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
plugin: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
mcp: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
|
||||
projectList: () => {
|
||||
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
||||
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
|
||||
},
|
||||
projectCurrent: () =>
|
||||
Effect.succeed({
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
},
|
||||
}),
|
||||
worktreeList: () =>
|
||||
Effect.succeed([
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git",
|
||||
})),
|
||||
]),
|
||||
worktreeCreate: (ctx) => {
|
||||
const input = record(ctx.payload) ? ctx.payload : {}
|
||||
return Effect.succeed({
|
||||
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
|
||||
typeof input.name === "string" ? input.name : "copy"
|
||||
}`,
|
||||
})
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (path === "/api/agent")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === "/api/provider")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: currentProviders(providerConfig(config)),
|
||||
})
|
||||
if (path === "/api/model")
|
||||
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||
if (path === "/api/model/default")
|
||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/mcp/resource")
|
||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
||||
if (integration && route.request().method() === "GET")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: {
|
||||
id: integration,
|
||||
name: integration,
|
||||
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
})
|
||||
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||
if (integrationConnect && route.request().method() === "POST") {
|
||||
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/project") {
|
||||
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
||||
return json(route, [
|
||||
{
|
||||
...project,
|
||||
canonical: project.canonical ?? project.worktree ?? config.directory,
|
||||
},
|
||||
])
|
||||
}
|
||||
if (path === "/api/project/current")
|
||||
return json(route, {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
})
|
||||
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
||||
if (worktree && route.request().method() === "GET")
|
||||
return json(route, [
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git",
|
||||
})),
|
||||
])
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
if (worktree && route.request().method() === "POST") {
|
||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||
}
|
||||
if (worktree && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
if (path === "/api/form/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
})
|
||||
if (path === "/api/vcs")
|
||||
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/fs/list" && config.fileList)
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: await config.fileList(url.searchParams.get("path") ?? ""),
|
||||
})
|
||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||
if (fileRead && config.fileContent) {
|
||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||
const content =
|
||||
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||
}
|
||||
if (path === "/api/fs/find" && config.findFiles) {
|
||||
const entries = await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("type") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
})
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: Array.isArray(entries)
|
||||
? entries.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? {
|
||||
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
||||
path: entry,
|
||||
absolute: `${config.directory}/${entry}`,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: entries,
|
||||
})
|
||||
}
|
||||
if (path === "/api/shell" && route.request().method() === "GET")
|
||||
return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (path === "/api/session") {
|
||||
if (route.request().method() === "POST") {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>
|
||||
const created = currentSession(
|
||||
{
|
||||
id: "ses_mock_created",
|
||||
projectID: (config.project as { id?: string }).id,
|
||||
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||
},
|
||||
config.directory,
|
||||
)
|
||||
config.sessions.push(created)
|
||||
return json(route, { data: created })
|
||||
}
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => {
|
||||
if (parentID === null) return true
|
||||
if (parentID === "null") return session.parentID === undefined
|
||||
return session.parentID === parentID
|
||||
})
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
!search ||
|
||||
String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||
return json(route, {
|
||||
data: data.map((session) => currentSession(session, config.directory)),
|
||||
cursor: { next },
|
||||
})
|
||||
}
|
||||
if (path === "/api/session/active") {
|
||||
const statuses = (
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
|
||||
) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
worktreeRemove: () => noContent,
|
||||
worktreeRefresh: () => noContent,
|
||||
location: () => Effect.succeed(location(config)),
|
||||
permissionRequests: () =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||
currentPermission,
|
||||
),
|
||||
}),
|
||||
formRequests: () =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
}),
|
||||
vcs: () =>
|
||||
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
|
||||
fsList: (ctx) =>
|
||||
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
|
||||
Effect.map((data) => ({ location: location(config), data })),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
||||
if (sessionForm && route.request().method() === "GET") {
|
||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
||||
return json(route, { data: [] })
|
||||
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
|
||||
if (sessionPermission && route.request().method() === "GET") {
|
||||
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||
return json(route, {
|
||||
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const revertStage = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/)?.[1]
|
||||
if (revertStage && route.request().method() === "POST") {
|
||||
const body = route.request().postDataJSON()
|
||||
if (!body || typeof body !== "object" || !("messageID" in body) || typeof body.messageID !== "string") {
|
||||
return json(route, { error: "Invalid revert request" }, undefined, 400)
|
||||
}
|
||||
config.onRevertStage?.({ sessionID: revertStage, messageID: body.messageID })
|
||||
return json(route, { data: { messageID: body.messageID } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
||||
return json(route, {
|
||||
data: currentSession(session, config.directory),
|
||||
})
|
||||
}
|
||||
|
||||
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message =
|
||||
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
|
||||
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, { data: message })
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort)
|
||||
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
|
||||
return route.fallback()
|
||||
})
|
||||
fsFind: (ctx) =>
|
||||
Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((entries) => ({
|
||||
location: location(config),
|
||||
data: Array.isArray(entries)
|
||||
? entries.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? {
|
||||
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
||||
path: entry,
|
||||
absolute: `${config.directory}/${entry}`,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
: entries,
|
||||
})),
|
||||
),
|
||||
shell: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
ptyConnectToken: () =>
|
||||
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||
sessionList: (ctx) => {
|
||||
const sessions = config.sessions
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return (
|
||||
!ctx.query.directory ||
|
||||
location?.directory === ctx.query.directory ||
|
||||
session.directory === ctx.query.directory
|
||||
)
|
||||
})
|
||||
.filter((session) => {
|
||||
if (ctx.query.parentID === undefined) return true
|
||||
if (ctx.query.parentID === "null") return session.parentID === undefined
|
||||
return session.parentID === ctx.query.parentID
|
||||
})
|
||||
.filter((session) =>
|
||||
ctx.query.search === undefined
|
||||
? true
|
||||
: String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(ctx.query.search.toLowerCase()),
|
||||
)
|
||||
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
|
||||
const offset = Number(ctx.query.cursor ?? 0)
|
||||
const limit = ctx.query.limit ?? 50
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
return Effect.succeed({
|
||||
data: data.map((session) => currentSession(session, config.directory)),
|
||||
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
|
||||
})
|
||||
},
|
||||
sessionCreate: (ctx) => {
|
||||
const payload = record(ctx.payload) ? ctx.payload : {}
|
||||
const created = currentSession(
|
||||
{
|
||||
id: "ses_mock_created",
|
||||
projectID: (config.project as { id?: string }).id,
|
||||
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||
},
|
||||
config.directory,
|
||||
)
|
||||
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
|
||||
},
|
||||
sessionActive: () => {
|
||||
const statuses = (
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
|
||||
) as Record<string, { type?: string }>
|
||||
return Effect.succeed({
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
),
|
||||
),
|
||||
})
|
||||
},
|
||||
sessionGet: (ctx) => {
|
||||
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
|
||||
return session
|
||||
? Effect.succeed({ data: currentSession(session, config.directory) })
|
||||
: Effect.fail(new MockNotFound({ message: "Session not found" }))
|
||||
},
|
||||
sessionRemove: () => noContent,
|
||||
sessionShell: () => noContent,
|
||||
sessionForm: (ctx) => {
|
||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||
return Effect.succeed({
|
||||
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
|
||||
})
|
||||
},
|
||||
sessionFormReply: () => noContent,
|
||||
sessionFormCancel: () => noContent,
|
||||
sessionBackground: () => noContent,
|
||||
sessionInbox: () => Effect.succeed({ data: [] }),
|
||||
sessionPermission: (ctx) => {
|
||||
const permissions =
|
||||
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||
return Effect.succeed({
|
||||
data: permissions
|
||||
.map(currentPermission)
|
||||
.filter((permission) => permission.sessionID === ctx.params.sessionID),
|
||||
})
|
||||
},
|
||||
sessionPermissionReply: () => noContent,
|
||||
sessionRename: () => noContent,
|
||||
sessionInterrupt: () => noContent,
|
||||
sessionRevertStage: (ctx) => {
|
||||
const payload = record(ctx.payload) ? ctx.payload : {}
|
||||
const messageID = payload.messageID
|
||||
if (typeof messageID !== "string") {
|
||||
return Effect.fail(new MockBadRequest({ message: "Invalid revert request" }))
|
||||
}
|
||||
return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe(
|
||||
Effect.as({ data: { messageID } }),
|
||||
)
|
||||
},
|
||||
sessionRevertClear: () => noContent,
|
||||
sessionRevertCommit: () => noContent,
|
||||
messageGet: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
|
||||
yield* delay
|
||||
const message =
|
||||
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
|
||||
config
|
||||
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
|
||||
.items.find((item) => item.id === ctx.params.messageID)
|
||||
if (!message) return yield* new MockNotFound({ message: "Message not found" })
|
||||
return { data: message }
|
||||
}),
|
||||
messageList: (ctx) => {
|
||||
const token = ctx.query.cursor
|
||||
const before = token ? state.cursors.get(token) : undefined
|
||||
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
|
||||
return Effect.gen(function* () {
|
||||
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
|
||||
if (config.beforeMessagesResponse) {
|
||||
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
|
||||
}
|
||||
yield* delay
|
||||
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
|
||||
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
|
||||
if (cursor) state.cursors.set(cursor, pageData.cursor!)
|
||||
return {
|
||||
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||
cursor: { next: cursor },
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function location(config: MockServerConfig) {
|
||||
@@ -595,24 +613,3 @@ function jsonValue(value: unknown): JsonValue | undefined {
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body ?? null),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop": "./src/desktop.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./wsl/types": "./src/wsl/types.ts",
|
||||
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
|
||||
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
|
||||
"./updater": "./src/shell/updates/types.ts",
|
||||
"./wsl/types": "./src/servers/wsl/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
|
||||
+15
-105
@@ -4,72 +4,26 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
ErrorBoundary,
|
||||
type JSX,
|
||||
lazy,
|
||||
type ParentProps,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, ServersProvider } from "@/context/servers"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider } from "@/context/tabs"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { requireServerKey } from "./utils/session-route"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
import { GlobalProvider } from "@/runtime/server/runtime"
|
||||
import { HighlightsProvider } from "@/shell/updates/highlights"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
|
||||
import { SettingsProvider } from "@/settings/model"
|
||||
import { TabsProvider } from "@/shell/tabs/tabs"
|
||||
import { WslServersProvider } from "@/servers/wsl/context"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
|
||||
|
||||
import { Home } from "@/pages/home"
|
||||
import { ServerProvider } from "./context/server"
|
||||
|
||||
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
|
||||
)
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
export { preloadRoute }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
|
||||
exportDebugLogs?: () => Promise<string>
|
||||
@@ -100,39 +54,6 @@ function BodyTypography() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Server-agnostic providers shared across every route. These live in the shared
|
||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
command.register("desktop", () => {
|
||||
const commands: CommandOption[] = []
|
||||
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
||||
commands.push({
|
||||
id: "logs.export",
|
||||
title: language.t("command.logs.export"),
|
||||
category: language.t("command.category.settings"),
|
||||
onSelect: () => {
|
||||
void platform.exportDebugLogs?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
return commands
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppLayout(props: ParentProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(
|
||||
props: ParentProps<{
|
||||
locale?: Locale
|
||||
@@ -204,18 +125,7 @@ export function AppInterface(props: {
|
||||
<SettingsProvider>
|
||||
<GlobalProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
<AppRoutes />
|
||||
</Dynamic>
|
||||
</GlobalProvider>
|
||||
</SettingsProvider>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Show } from "solid-js"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||
|
||||
type ServerConnectionFormController = {
|
||||
state: {
|
||||
adding: () => boolean
|
||||
busy: () => boolean
|
||||
value: () => string
|
||||
name: () => string
|
||||
username: () => string
|
||||
password: () => string
|
||||
error: () => string
|
||||
status: () => boolean | undefined
|
||||
}
|
||||
change: {
|
||||
value: (value: string) => void
|
||||
name: (value: string) => void
|
||||
username: (value: string) => void
|
||||
password: (value: string) => void
|
||||
}
|
||||
reset: () => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onNameChange: (value: string) => void
|
||||
onUsernameChange: (value: string) => void
|
||||
onPasswordChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onBack()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
props.onSubmit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.url")}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.busy}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.name")}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
defaultValue={props.name}
|
||||
disabled={props.busy}
|
||||
onChange={props.onNameChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2 min-w-0">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.username")}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
defaultValue={props.username}
|
||||
disabled={props.busy}
|
||||
onChange={props.onUsernameChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
label={language.t("dialog.server.add.password")}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
defaultValue={props.password}
|
||||
disabled={props.busy}
|
||||
onChange={props.onPasswordChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: {
|
||||
domain: ServerCollectionController
|
||||
onAdd: () => void
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<List
|
||||
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||
search={{
|
||||
placeholder: language.t("dialog.server.search.placeholder"),
|
||||
autofocus: false,
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={props.domain.collection.items}
|
||||
key={(x) => x.http.url}
|
||||
divider={true}
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-center w-5">
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||
status={props.domain.collection.health()[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show when={i.type === "http"}>
|
||||
<Menu appearance="standard">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="dot-grid" />}
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="mt-1">
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
props.onEdit(i)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
</Menu.Item>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.connection.canRemove(key)}>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
onSelect={() => props.domain.connection.remove(key)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="neutral"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={props.onAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<ServerForm
|
||||
value={props.form.state.value()}
|
||||
name={props.form.state.name()}
|
||||
username={props.form.state.username()}
|
||||
password={props.form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={props.form.state.busy()}
|
||||
error={props.form.state.error()}
|
||||
status={props.form.state.status()}
|
||||
onChange={props.form.change.value}
|
||||
onNameChange={props.form.change.name}
|
||||
onUsernameChange={props.form.change.username}
|
||||
onPasswordChange={props.form.change.password}
|
||||
onSubmit={props.form.submit}
|
||||
onBack={props.form.reset}
|
||||
/>
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="contrast"
|
||||
size="large"
|
||||
onClick={props.form.submit}
|
||||
disabled={props.form.state.busy()}
|
||||
class="px-3 py-1.5"
|
||||
>
|
||||
{props.form.state.busy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: props.form.state.adding()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { type Component, type JSX } from "solid-js"
|
||||
|
||||
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { DialogSettings } from "./dialog-settings-v2"
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div data-component="settings-v2-list">{props.children}</div>
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export interface SettingsRowV2Props {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-v2-row">
|
||||
<div data-slot="settings-v2-row-copy">
|
||||
<div data-slot="settings-v2-row-title">{props.title}</div>
|
||||
<div data-slot="settings-v2-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-v2-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Taken from https://www.solid-ui.com/docs/components/drawer
|
||||
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
|
||||
*/
|
||||
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
|
||||
import DrawerPrimitive from "@corvu/drawer"
|
||||
|
||||
const Drawer = DrawerPrimitive
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
|
||||
|
||||
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
|
||||
const drawerContext = DrawerPrimitive.useContext()
|
||||
const overlayStyle = () => {
|
||||
const state = drawerContext.transitionState()
|
||||
if (state === "opening" || state === "closing") return undefined
|
||||
const open = drawerContext.openPercentage()
|
||||
return {
|
||||
opacity: open,
|
||||
"backdrop-filter": `blur(${4 * open}px)`,
|
||||
}
|
||||
}
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
class={props.class}
|
||||
classList={{
|
||||
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
|
||||
}}
|
||||
style={overlayStyle()}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
|
||||
class?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
class={props.class}
|
||||
classList={{
|
||||
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
|
||||
}
|
||||
|
||||
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
|
||||
}
|
||||
|
||||
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
|
||||
|
||||
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Label
|
||||
class={props.class}
|
||||
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
|
||||
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
class={props.class}
|
||||
classList={{
|
||||
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
import type { createComposerSubmission } from "./submission-state"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { useCommand, type CommandOption } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "./editor/dom"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
@@ -40,7 +40,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
if (cursor !== null) setCursorPosition(editor, cursor)
|
||||
})
|
||||
}
|
||||
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
|
||||
owner.run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
|
||||
export type PromptComment = {
|
||||
path: string
|
||||
@@ -3,13 +3,13 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Show, createMemo, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { STORY_MODEL, emptySessionDocument, pendingAndQueuedDocument } from "@opencode-ai/session-ui/storybook"
|
||||
import { Composer } from "./composer"
|
||||
import type { ComposerModel } from "./model"
|
||||
import { createComposerEditor } from "./editor/interaction"
|
||||
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { promptLength } from "./prompt-parts"
|
||||
import { SessionPreview } from "@/session/story-model"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { resolveSessionComposerSelection } from "@/session/composer/selection"
|
||||
@@ -57,7 +58,7 @@ function ComposerStory(props: {
|
||||
}) {
|
||||
const [draft, setDraft] = createStore<ComposerPersistedState>({
|
||||
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
|
||||
cursor: props.prompt ? promptLength(props.prompt) : 0,
|
||||
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
|
||||
context: { items: props.comments ?? [] },
|
||||
})
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ComposerEditor } from "./editor/editor"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
|
||||
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export function Composer(props: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
} from "../types"
|
||||
import { promptLength } from "../prompt-parts"
|
||||
|
||||
export type ComposerStateStore = [
|
||||
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
|
||||
@@ -134,7 +135,3 @@ function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function promptLength(prompt: ComposerPrompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
options: Accessor<ComposerOption[]>
|
||||
@@ -434,16 +435,6 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
|
||||
return prompt.map((part) =>
|
||||
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
|
||||
)
|
||||
}
|
||||
|
||||
function promptLength(prompt: ComposerPersistedState["prompt"]) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
function editorCursor(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { upgradeHistoryState } from "./store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
@@ -34,31 +34,32 @@ describe("Composer history", () => {
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("insertion isolates canonical entries from source mutations", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 0,
|
||||
end: 9,
|
||||
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 0 },
|
||||
},
|
||||
]
|
||||
const comments = [comment("c1")]
|
||||
const entries = prependHistoryEntry([], prompt, comments)
|
||||
const stored = entries[0]
|
||||
|
||||
if (prompt[0]?.type !== "file" || stored?.prompt[0]?.type !== "file") throw new Error("expected file")
|
||||
prompt[0].selection!.startLine = 9
|
||||
comments[0].selection.start = 9
|
||||
|
||||
expect(stored.prompt[0].selection?.startLine).toBe(1)
|
||||
expect(stored.comments[0]?.selection.start).toBe(2)
|
||||
})
|
||||
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(promptLength(copy)).toBe(12)
|
||||
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||
copy[1].selection!.startLine = 9
|
||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
@@ -20,19 +21,6 @@ export type PromptHistoryEntry = {
|
||||
|
||||
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||
|
||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "skill") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: part.selection ? { ...part.selection } : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
return {
|
||||
start: selection.start,
|
||||
@@ -49,17 +37,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||
return {
|
||||
prompt: clonePromptParts(entry.prompt),
|
||||
comments: clonePromptHistoryComments(entry.comments),
|
||||
}
|
||||
}
|
||||
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
export function prependHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
@@ -75,7 +52,7 @@ export function prependHistoryEntry(
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
|
||||
const entry = {
|
||||
prompt: clonePromptParts(prompt),
|
||||
prompt: clonePrompt(prompt),
|
||||
comments: clonePromptHistoryComments(comments),
|
||||
} satisfies PromptHistoryEntry
|
||||
const last = entries[0]
|
||||
@@ -96,9 +73,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
|
||||
)
|
||||
}
|
||||
|
||||
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
||||
const entryA = normalizePromptHistoryEntry(promptA)
|
||||
const entryB = normalizePromptHistoryEntry(promptB)
|
||||
function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistoryStoredEntry) {
|
||||
if (entryA.prompt.length !== entryB.prompt.length) return false
|
||||
for (let i = 0; i < entryA.prompt.length; i++) {
|
||||
const partA = entryA.prompt[i]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
clonePromptParts,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
@@ -23,7 +23,7 @@ export function upgradeHistoryState(value: unknown) {
|
||||
return {
|
||||
...value,
|
||||
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
|
||||
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
|
||||
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
|
||||
return [entry as PromptHistoryStoredEntry]
|
||||
@@ -64,7 +64,7 @@ export function createComposerHistory() {
|
||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
|
||||
const saved = clonePromptParts(prompt)
|
||||
const saved = clonePrompt(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
|
||||
@@ -4,21 +4,21 @@ import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
import { createComponent, createEffect, createMemo, on } from "solid-js"
|
||||
import type { ComposerSuggestion } from "./types"
|
||||
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspaces/files/model"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ComposerAdapter, ComposerControls } from "./adapter"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
@@ -284,11 +284,7 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
store: prompt.store,
|
||||
state: interaction,
|
||||
history: {
|
||||
entries: (mode) =>
|
||||
history.entries(mode).map((value) => {
|
||||
const entry = normalizePromptHistoryEntry(value)
|
||||
return { prompt: entry.prompt, metadata: entry.comments }
|
||||
}),
|
||||
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
|
||||
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||
capture: historyComments,
|
||||
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||
|
||||
@@ -2,12 +2,12 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useTabs, type Tab } from "@/context/tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useTabs, type Tab } from "@/shell/tabs/tabs"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import {
|
||||
createComposerReady,
|
||||
createComposerState,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
|
||||
describe("composer prompt parts", () => {
|
||||
test("clones parts shallowly and copies file selections", () => {
|
||||
const original: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/a.ts",
|
||||
content: "@src/a.ts",
|
||||
start: 3,
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
|
||||
const copy = clonePrompt(original)
|
||||
|
||||
expect(copy).not.toBe(original)
|
||||
expect(copy[0]).not.toBe(original[0])
|
||||
expect(copy[1]).not.toBe(original[1])
|
||||
expect(copy[2]).not.toBe(original[2])
|
||||
if (copy[1]?.type !== "file" || original[1]?.type !== "file") throw new Error("expected file parts")
|
||||
if (copy[2]?.type !== "image" || original[2]?.type !== "image") throw new Error("expected image parts")
|
||||
expect(copy[2].blob).toBe(original[2].blob)
|
||||
expect(copy[1].selection).not.toBe(original[1].selection)
|
||||
copy[1].selection!.startLine = 9
|
||||
expect(original[1].selection?.startLine).toBe(1)
|
||||
})
|
||||
|
||||
test("counts the content of text and mention parts", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "one", start: 0, end: 3 },
|
||||
{ type: "agent", content: "@build", start: 3, end: 9, name: "build" },
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
|
||||
expect(promptLength(prompt)).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Prompt } from "./state"
|
||||
|
||||
export function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) =>
|
||||
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
|
||||
)
|
||||
}
|
||||
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
|
||||
import { wire } from "../test-fixture"
|
||||
|
||||
describe("extractPromptFromMessage", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "check these",
|
||||
@@ -13,7 +14,7 @@ describe("extractPromptFromMessage", () => {
|
||||
{ data: "BBB", mime: "application/pdf", source: { type: "inline" }, name: "b.pdf" },
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
const result = extractPromptFromMessage(message)
|
||||
|
||||
@@ -36,7 +37,7 @@ describe("extractPromptFromMessage", () => {
|
||||
})
|
||||
|
||||
test("restores optimistic data URLs and review comments", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
@@ -60,7 +61,7 @@ describe("extractPromptFromMessage", () => {
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "visible text" },
|
||||
@@ -72,7 +73,7 @@ describe("extractPromptFromMessage", () => {
|
||||
})
|
||||
|
||||
test("keeps the directory of a file mention without an at-sign", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "inspect src/client.ts",
|
||||
@@ -86,7 +87,7 @@ describe("extractPromptFromMessage", () => {
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "inspect " },
|
||||
@@ -95,25 +96,25 @@ describe("extractPromptFromMessage", () => {
|
||||
})
|
||||
|
||||
test("uses model text when presentation metadata is incomplete", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
metadata: { displayText: "partial display text" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
|
||||
test("restores skill mentions as structured Composer parts", () => {
|
||||
const message = {
|
||||
const message = wire<SessionMessageUser>({
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "Use @review",
|
||||
skills: [{ id: "review", name: "Review", mention: { text: "@review", start: 4, end: 11 } }],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
})
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "Use " },
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import { createLegacyBlobReference } from "@/utils/draft-store"
|
||||
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { readPromptPresentation } from "./comment-note"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
|
||||
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
|
||||
import type { ComposerControls } from "./adapter"
|
||||
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal, useServerCtx } from "@/context/global"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal, type ModelKey, type ModelSelection } from "@/context/local"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useData } from "@/context/server"
|
||||
import { normalizeAgentList } from "@/context/global-sync/utils"
|
||||
import { useModels } from "@/context/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
|
||||
import { useComposerState } from "./persistence"
|
||||
|
||||
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
|
||||
@@ -159,56 +153,3 @@ export function createComposerModelSelection(input: {
|
||||
|
||||
return selection
|
||||
}
|
||||
|
||||
export function createComposerProjectControls(props: { draftId: string }) {
|
||||
const server = useServers()
|
||||
const serverSDK = useServerSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const projectServer = () => serverSDK.server
|
||||
const projectServerCtx = useServerCtx(projectServer)
|
||||
const projects = createMemo(() => {
|
||||
if (server.list.length <= 1) {
|
||||
return projectServerCtx().projects.list()
|
||||
}
|
||||
return server.list.flatMap((conn) => {
|
||||
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
|
||||
return global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.map((project) => ({ ...project, server: item }))
|
||||
})
|
||||
})
|
||||
const selectProject = (worktree: string, serverKey?: string) => {
|
||||
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
|
||||
if (!conn) return
|
||||
|
||||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(props.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
|
||||
}
|
||||
|
||||
const addProject = (title: string, serverKey?: string) => {
|
||||
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory, serverKey)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<PromptProjectControls>(() => ({
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { BlobReference } from "@/utils/draft-store"
|
||||
import type { Platform } from "@/context/platform"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { BlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { clonePrompt } from "./prompt-parts"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -108,26 +109,6 @@ type InitialPrompt = {
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: FileSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
if (part.type === "agent") return { ...part }
|
||||
if (part.type === "skill") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
|
||||
@@ -48,7 +49,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt: clonePromptParts(input.adapter.state.current()),
|
||||
prompt: clonePrompt(input.adapter.state.current()),
|
||||
context: input.adapter.state.context.items().map((item) => ({
|
||||
...item,
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
@@ -317,7 +318,3 @@ function failSubmission(
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
}
|
||||
|
||||
function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createCatalogSync } from "./catalog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
test("invalidates the catalog for the event location", async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const one = [ServerScope.local, "/one", "providers"] as const
|
||||
const integrations = [ServerScope.local, "/one", "integrations"] as const
|
||||
const two = [ServerScope.local, "/two", "providers"] as const
|
||||
queryClient.setQueryData(one, { providers: ["one"] })
|
||||
queryClient.setQueryData(integrations, { integrations: ["one"] })
|
||||
queryClient.setQueryData(two, { providers: ["two"] })
|
||||
const catalog = createCatalogSync({
|
||||
scope: ServerScope.local,
|
||||
queryClient,
|
||||
active: () => [pathKey("/one"), pathKey("/two")],
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
|
||||
})
|
||||
|
||||
test("invalidates global and active catalogs after connection", async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const global = [ServerScope.local, null, "providers"] as const
|
||||
const active = [ServerScope.local, "/active", "providers"] as const
|
||||
const passive = [ServerScope.local, "/passive", "providers"] as const
|
||||
queryClient.setQueryData(global, {})
|
||||
queryClient.setQueryData(active, {})
|
||||
queryClient.setQueryData(passive, {})
|
||||
const catalog = createCatalogSync({
|
||||
scope: ServerScope.local,
|
||||
queryClient,
|
||||
active: () => [pathKey("/active")],
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.handleEvent({ type: "server.connected" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
|
||||
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey, type PathKey } from "@/utils/path-key"
|
||||
|
||||
type CatalogEvent = {
|
||||
type: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export function createCatalogSync(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
active: () => PathKey[]
|
||||
load: (directory: PathKey | null) => Promise<void>
|
||||
}) {
|
||||
function handleEvent(event: CatalogEvent) {
|
||||
if (event.type === "server.connected") {
|
||||
void refreshActive().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "catalog.updated" ||
|
||||
event.type === "integration.updated" ||
|
||||
event.type === "integration.connection.updated"
|
||||
) {
|
||||
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(directory: PathKey | null) {
|
||||
await Promise.all(
|
||||
["providers", "integrations"].map((resource) =>
|
||||
input.queryClient.invalidateQueries({
|
||||
queryKey: [input.scope, directory, resource],
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
}),
|
||||
),
|
||||
)
|
||||
await input.load(directory)
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
handleEvent,
|
||||
refresh,
|
||||
refreshActive,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
|
||||
export { type Platform, PlatformProvider } from "./context/platform"
|
||||
export { ServerConnection, useServers } from "./context/servers"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { createDraftStore } from "./utils/draft-store"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
|
||||
export { useCommand } from "./shell/commands/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { useWslServers } from "./servers/wsl/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
+13
-101
@@ -1,18 +1,16 @@
|
||||
// @refresh reload
|
||||
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { init } from "@sentry/solid"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { loadInitialLocale } from "@/context/language"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
import { createBrowserDraftStore } from "@/utils/draft-store"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { authFromToken } from "@/utils/server"
|
||||
import { loadInitialLocale } from "@/runtime/i18n/language"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createWebPlatform } from "@/runtime/platform/web"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import zh from "@/runtime/i18n/zh"
|
||||
import { authFromToken } from "@/runtime/server/api"
|
||||
import pkg from "../package.json"
|
||||
import { ServerConnection } from "./context/servers"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
const getLocale = () => {
|
||||
if (typeof navigator !== "object") return "en" as const
|
||||
@@ -30,85 +28,11 @@ const getRootNotFoundError = () => {
|
||||
return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
|
||||
}
|
||||
|
||||
const getStorage = (key: string) => {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const setStorage = (key: string, value: string | null) => {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
if (value !== null) {
|
||||
localStorage.setItem(key, value)
|
||||
return
|
||||
}
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
|
||||
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
|
||||
|
||||
const notify: Platform["notify"] = async (title, description, onClick) => {
|
||||
if (!("Notification" in window)) return
|
||||
|
||||
const permission =
|
||||
Notification.permission === "default"
|
||||
? await Notification.requestPermission().catch(() => "denied")
|
||||
: Notification.permission
|
||||
|
||||
if (permission !== "granted") return
|
||||
|
||||
const inView = document.visibilityState === "visible" && document.hasFocus()
|
||||
if (inView) return
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||
})
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
onClick?.()
|
||||
notification.close()
|
||||
}
|
||||
}
|
||||
|
||||
const openExternal: Platform["openExternal"] = (value) => {
|
||||
if (!URL.canParse(value)) return
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
|
||||
window.open(url.href, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const restart: Platform["restart"] = async () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
const getDefaultUrl = () => {
|
||||
const lsDefault = readDefaultServerUrl()
|
||||
if (lsDefault) return lsDefault
|
||||
return getCurrentUrl()
|
||||
}
|
||||
|
||||
const clearAuthToken = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (!params.has("auth_token")) return
|
||||
@@ -116,22 +40,10 @@ const clearAuthToken = () => {
|
||||
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
draftStore: createBrowserDraftStore(),
|
||||
version: pkg.version,
|
||||
openExternal,
|
||||
restart,
|
||||
notify,
|
||||
getDefaultServer: async () => {
|
||||
const stored = readDefaultServerUrl()
|
||||
return stored ? ServerConnection.Key.make(stored) : null
|
||||
},
|
||||
setDefaultServer: writeDefaultServerUrl,
|
||||
}
|
||||
const web = createWebPlatform(pkg.version)
|
||||
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
|
||||
release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`,
|
||||
@@ -157,16 +69,16 @@ if (root instanceof HTMLElement) {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: getCurrentUrl(),
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<PlatformProvider value={web.platform}>
|
||||
<AppBaseProviders locale={locale}>
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useGlobal, useServerCtx } from "@/context/global"
|
||||
import { type HomeProjectSelection, useLayout } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
|
||||
export function createHomeController() {
|
||||
+16
-16
@@ -1,18 +1,18 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { useServerActionsController } from "@/servers/registry/controller"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
import { type LocalProject } from "@/shell/state/layout"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import type { HomeController } from "../model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
|
||||
export function createHomeProjectsController(home: HomeController) {
|
||||
const platform = usePlatform()
|
||||
@@ -62,8 +62,8 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => {
|
||||
void import("@/components/settings-v2/dialog-server-v2").then(({ DialogServerV2 }) => {
|
||||
void dialog.show(() => <DialogServerV2 mode="edit" server={conn} />)
|
||||
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
|
||||
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
|
||||
})
|
||||
},
|
||||
focus: home.selection.focusServer,
|
||||
@@ -76,8 +76,8 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
add: home.project.add,
|
||||
openNewSession: home.project.openProjectNewSession,
|
||||
edit: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
|
||||
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
|
||||
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
|
||||
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
|
||||
})
|
||||
},
|
||||
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import type { HomeProjectsController } from "./home-projects-controller"
|
||||
import { HomeProjectsView } from "./home-projects-view"
|
||||
import type { HomeScrollController } from "./home-scroll-controller"
|
||||
import type { HomeProjectsController } from "./controller"
|
||||
import { HomeProjectsView } from "./view"
|
||||
import type { HomeScrollController } from "../scroll"
|
||||
|
||||
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
|
||||
return (
|
||||
+97
-85
@@ -11,15 +11,15 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu"
|
||||
import { ServerHealthIndicator } from "@/servers/registry/row"
|
||||
import { type ServerHealth } from "@/runtime/server/health"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
|
||||
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
|
||||
@@ -196,6 +196,7 @@ function HomeServerRow(props: {
|
||||
health: ServerHealth | undefined
|
||||
}) {
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const incompatible = () => !!props.health?.incompatible
|
||||
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
|
||||
const contextMenuID = () => serverContextMenuID(props.server)
|
||||
onCleanup(() => {
|
||||
@@ -203,96 +204,107 @@ function HomeServerRow(props: {
|
||||
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
|
||||
})
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
placement="top"
|
||||
class="flex h-7 w-full min-w-0"
|
||||
inactive={!incompatible()}
|
||||
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
|
||||
>
|
||||
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="pr-16 disabled:opacity-60"
|
||||
class="pr-16"
|
||||
classList={{ "opacity-60": !healthy() && !incompatible() }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.onFocusServer(props.server)}
|
||||
>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class={`
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.onFocusServer(props.server)}
|
||||
>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class={`
|
||||
-ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
|
||||
rounded-[4px] text-v2-icon-icon-muted
|
||||
`}
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||
"cursor-default opacity-40": !canToggle(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.onToggleCollapsed(props.server)
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
|
||||
<Show when={props.server.label}>
|
||||
{(label) => (
|
||||
<span
|
||||
class={`
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||
"cursor-default opacity-40": !canToggle(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.onToggleCollapsed(props.server)
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed || !canToggle() ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>
|
||||
{props.server.displayName ?? new URL(props.server.http.url).host}
|
||||
</span>
|
||||
<Show when={props.server.label}>
|
||||
{(label) => (
|
||||
<span
|
||||
class={`
|
||||
shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
|
||||
text-[9px] leading-none text-v2-text-text-muted
|
||||
`}
|
||||
>
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</HomeProjectNavButton>
|
||||
<div
|
||||
class={`
|
||||
>
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</HomeProjectNavButton>
|
||||
<div
|
||||
class={`
|
||||
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
|
||||
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
|
||||
`}
|
||||
data-menu={props.contextMenuOpen(contextMenuID())}
|
||||
>
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer}
|
||||
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
|
||||
canRemove={props.canRemoveServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
|
||||
onRemove={() => props.onRemoveServer(props.server)}
|
||||
open={props.contextMenuOpen(contextMenuID())}
|
||||
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
|
||||
/>
|
||||
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButton
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
data-menu={props.contextMenuOpen(contextMenuID())}
|
||||
>
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer}
|
||||
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
|
||||
canRemove={props.canRemoveServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
|
||||
onRemove={() => props.onRemoveServer(props.server)}
|
||||
open={props.contextMenuOpen(contextMenuID())}
|
||||
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButton
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { createHomeController } from "./home/home-controller"
|
||||
import { createHomeProjectsController } from "./home/home-projects-controller"
|
||||
import { HomeUtilityNav } from "./home/home-projects-view"
|
||||
import { HomeProjects } from "./home/home-projects"
|
||||
import { createHomeScrollController } from "./home/home-scroll-controller"
|
||||
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||
import { HomeSessions } from "./home/home-sessions"
|
||||
import { createHomeController } from "./model"
|
||||
import { createHomeProjectsController } from "./projects/controller"
|
||||
import { HomeUtilityNav } from "./projects/view"
|
||||
import { HomeProjects } from "./projects/region"
|
||||
import { createHomeScrollController } from "./scroll"
|
||||
import { createHomeSessionSearchController } from "./sessions/search"
|
||||
import { createHomeSessionsController } from "./sessions/controller"
|
||||
import { HomeSessions } from "./sessions/region"
|
||||
|
||||
export function Home() {
|
||||
const home = createHomeController()
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeSessionGroup } from "./home-sessions-controller"
|
||||
import type { HomeSessionGroup } from "./sessions/controller"
|
||||
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||
+7
-5
@@ -1,7 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import type { ServerConnection } from "@/context/servers"
|
||||
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
@@ -18,7 +20,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||
session: wire<Pick<SessionInfo, "id" | "location">>({ id: "ses_1", location: { directory: "/workspace" } }),
|
||||
archive: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
@@ -36,7 +38,7 @@ test("reports archive failures without removing the session", async () => {
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||
session: wire<Pick<SessionInfo, "id" | "location">>({ id: "ses_1", location: { directory: "/workspace" } }),
|
||||
archive: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import type { ServerConnection } from "@/context/servers"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type HomeSession = Pick<SessionInfo, "id" | "location">
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { commandPaletteOptions, useCommand } from "@/shell/commands/command"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createServerSessionEntries,
|
||||
type CommandPaletteEntry,
|
||||
} from "@/shell/commands/palette"
|
||||
import { CommandPaletteView, matchesCommandPaletteEntry } from "@/shell/commands/dialog"
|
||||
|
||||
export function HomeCommandPalette(props: {
|
||||
server: ServerConnection.Any
|
||||
onSelectSession: (entry: CommandPaletteEntry) => void
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = global.ensureServerCtx(props.server)
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const commandEntries = createMemo(() => {
|
||||
const category = language.t("palette.group.commands")
|
||||
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
||||
})
|
||||
const sessions = createServerSessionEntries({
|
||||
server: ServerConnection.key(props.server),
|
||||
opened: server.projects.list,
|
||||
stored: () => server.sync.data.project,
|
||||
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||
state.cleanup?.()
|
||||
state.cleanup = undefined
|
||||
if (item?.type !== "command") return
|
||||
state.cleanup = item.option?.onHighlight?.()
|
||||
}
|
||||
const select = (item: CommandPaletteEntry | undefined) => {
|
||||
if (!item) return
|
||||
state.committed = true
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
if (item.type === "command") {
|
||||
item.option?.onSelect?.("palette")
|
||||
return
|
||||
}
|
||||
if (item.type === "session") props.onSelectSession(item)
|
||||
}
|
||||
const loadItems = async (text: string) => {
|
||||
const query = text.trim()
|
||||
if (!query) return commandEntries().slice(0, 5)
|
||||
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.committed) return
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
return (
|
||||
<CommandPaletteView
|
||||
placeholder={language.t("palette.search.placeholder.home")}
|
||||
loadItems={loadItems}
|
||||
highlight={highlight}
|
||||
select={select}
|
||||
close={() => dialog.close()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+23
-28
@@ -3,25 +3,21 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { skipToken, useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
mergeHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./home-session-records"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
import { errorMessage } from "@/shell/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./home-session-records"
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
|
||||
@@ -105,9 +101,9 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
if (!conn) return
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
const { DialogHomeCommandPalette } = await import("@/components/dialog-command-palette")
|
||||
const { HomeCommandPalette } = await import("./command-palette")
|
||||
void dialog.show(() => (
|
||||
<DialogHomeCommandPalette
|
||||
<HomeCommandPalette
|
||||
server={conn}
|
||||
onSelectSession={(entry) => {
|
||||
if (!entry.sessionID || !entry.directory || !entry.server) return
|
||||
@@ -144,14 +140,13 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
const project =
|
||||
home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const project = home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const connKey = ServerConnection.key(conn)
|
||||
+10
-12
@@ -1,23 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
HOME_V2_SESSION_PAGE_LIMIT,
|
||||
loadHomeSessionIndex,
|
||||
parseHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "./home-session-index"
|
||||
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
|
||||
import { wire, type Wire } from "@/test-fixture"
|
||||
|
||||
const session = (id: string, input: Partial<SessionInfo> = {}) =>
|
||||
({
|
||||
const session = (id: string, input: Partial<Wire<SessionInfo>> = {}) =>
|
||||
wire<SessionInfo>({
|
||||
id,
|
||||
projectID: "project",
|
||||
title: id,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: "/repo" },
|
||||
...input,
|
||||
}) as SessionInfo
|
||||
})
|
||||
|
||||
describe("Home V2 session index", () => {
|
||||
describe("Home session index", () => {
|
||||
test("loads all pages", async () => {
|
||||
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
|
||||
const calls: Array<{ cursor?: string; parentID: null }> = []
|
||||
@@ -38,7 +36,7 @@ describe("Home V2 session index", () => {
|
||||
session("root"),
|
||||
session("child", { parentID: "root" }),
|
||||
session("archived", { time: { created: 1, updated: 1, archived: 2 } }),
|
||||
]).map((item) => item.id),
|
||||
]).map((item) => String(item.id)),
|
||||
).toEqual(["root"])
|
||||
})
|
||||
|
||||
@@ -49,6 +47,6 @@ describe("Home V2 session index", () => {
|
||||
1,
|
||||
now,
|
||||
)
|
||||
expect(result.map((item) => item.id)).toEqual(["b"])
|
||||
expect(result.map((item) => String(item.id))).toEqual(["b"])
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
|
||||
|
||||
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||
import { shouldOpenSessionInBackground } from "./open"
|
||||
|
||||
describe("shouldOpenSessionInBackground", () => {
|
||||
test("opens middle clicks in the background", () => {
|
||||
+9
-6
@@ -1,16 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { buildHomeSessionRecords } from "./home-session-records"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { buildHomeSessionRecords } from "./records"
|
||||
import { wire } from "@/test-fixture"
|
||||
|
||||
const session = (id: string, directory: string, projectID: string) =>
|
||||
({
|
||||
wire<SessionInfo>({
|
||||
id,
|
||||
projectID,
|
||||
title: id,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory },
|
||||
time: { created: 1, updated: 1 },
|
||||
}) as SessionInfo
|
||||
})
|
||||
|
||||
describe("buildHomeSessionRecords", () => {
|
||||
const opened = { id: "project-a", worktree: "/repo/a", expanded: true } as LocalProject
|
||||
@@ -23,7 +26,7 @@ describe("buildHomeSessionRecords", () => {
|
||||
projects: () => [opened],
|
||||
})
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a", "b"])
|
||||
expect(records.map((record) => String(record.session.id))).toEqual(["a", "b"])
|
||||
expect(records[1]?.project).toMatchObject({ id: "project-b", worktree: "/repo/b", expanded: false })
|
||||
})
|
||||
|
||||
@@ -34,6 +37,6 @@ describe("buildHomeSessionRecords", () => {
|
||||
projects: () => [opened],
|
||||
})
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
expect(records.map((record) => String(record.session.id))).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { compareSessionTime, displayName } from "@/pages/layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { compareSessionTime, displayName } from "@/shell/layout/helpers"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
|
||||
export type HomeSessionRecord = {
|
||||
session: SessionInfo
|
||||
@@ -29,10 +29,10 @@ export function buildHomeSessionRecords(input: {
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import type { HomeScrollController } from "./home-scroll-controller"
|
||||
import type { HomeSessionSearchController } from "./home-session-search-controller"
|
||||
import type { HomeSessionsController } from "./home-sessions-controller"
|
||||
import { HomeSessionsView } from "./home-sessions-view"
|
||||
import type { HomeScrollController } from "../scroll"
|
||||
import type { HomeSessionSearchController } from "./search"
|
||||
import type { HomeSessionsController } from "./controller"
|
||||
import { HomeSessionsView } from "./view"
|
||||
|
||||
export function HomeSessions(props: {
|
||||
sessions: HomeSessionsController
|
||||
+7
-7
@@ -1,13 +1,13 @@
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/servers"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { serverName } from "@/runtime/server/registry"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
|
||||
import type { HomeController } from "../model"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
|
||||
|
||||
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
|
||||
|
||||
+6
-6
@@ -6,18 +6,18 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { shouldOpenSessionInBackground } from "./open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
homeSessionSearchKey,
|
||||
type HomeSessionGroup,
|
||||
type HomeSessionRecord,
|
||||
type OpenSessionOptions,
|
||||
} from "./home-sessions-controller"
|
||||
} from "./controller"
|
||||
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
@@ -1,59 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { ProviderListResponse } from "@/types"
|
||||
import { selectProviderCatalog } from "./provider-catalog"
|
||||
|
||||
const catalog = (id: string): ProviderListResponse => ({
|
||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
||||
connected: [id],
|
||||
default: { [id]: `${id}-model` },
|
||||
})
|
||||
|
||||
test("selects the ready catalog for an explicit directory", () => {
|
||||
const directory = catalog("directory")
|
||||
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: true,
|
||||
directory: "/repo",
|
||||
catalog: { ready: true, providers: directory },
|
||||
}),
|
||||
).toBe(directory)
|
||||
})
|
||||
|
||||
test("returns an empty catalog while an explicit directory is unresolved", () => {
|
||||
expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: true,
|
||||
directory: "/repo",
|
||||
catalog: { ready: false, providers: catalog("directory") },
|
||||
}),
|
||||
).toEqual({ all: new Map(), connected: [], default: {} })
|
||||
})
|
||||
|
||||
test("uses the route catalog when it is ready", () => {
|
||||
const directory = catalog("directory")
|
||||
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: false,
|
||||
directory: "/repo",
|
||||
catalog: { ready: true, providers: directory },
|
||||
global: catalog("global"),
|
||||
}),
|
||||
).toBe(directory)
|
||||
})
|
||||
|
||||
test("falls back to the global catalog for route consumers", () => {
|
||||
const global = catalog("global")
|
||||
|
||||
expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
|
||||
expect(
|
||||
selectProviderCatalog({
|
||||
explicit: false,
|
||||
directory: "/repo",
|
||||
catalog: { ready: false, providers: catalog("directory") },
|
||||
global,
|
||||
}),
|
||||
).toBe(global)
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ProviderListResponse } from "@/types"
|
||||
|
||||
export const emptyProviderCatalog: ProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
|
||||
type DirectoryCatalog = {
|
||||
ready: boolean
|
||||
providers: ProviderListResponse
|
||||
}
|
||||
|
||||
type ProviderCatalogInput =
|
||||
| {
|
||||
explicit: true
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
}
|
||||
| {
|
||||
explicit: false
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
global: ProviderListResponse
|
||||
}
|
||||
|
||||
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
||||
if (input.directory && input.catalog?.ready) return input.catalog.providers
|
||||
if (input.explicit) return emptyProviderCatalog
|
||||
return input.global
|
||||
}
|
||||
@@ -306,7 +306,7 @@
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
|
||||
[data-slot="manage-models-scroll"] .settings-v2-panel {
|
||||
[data-slot="manage-models-scroll"] .settings-panel {
|
||||
scroll-timeline: --manage-models-scroll y;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServers as useServers } from "./context/servers"
|
||||
export { useSettings } from "./context/settings"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { useProviders } from "./hooks/use-providers"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./updater"
|
||||
export {
|
||||
type WslDistroProbe,
|
||||
type WslInstalledDistro,
|
||||
type WslJob,
|
||||
type WslOnlineDistro,
|
||||
type WslOpencodeCheck,
|
||||
type WslRuntimeCheck,
|
||||
type WslServerConfig,
|
||||
type WslServerItem,
|
||||
type WslServerRuntime,
|
||||
type WslServersEvent,
|
||||
type WslServersPlatform,
|
||||
type WslServersState,
|
||||
} from "./wsl/types"
|
||||
export { ServerConnection } from "./context/servers"
|
||||
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export { ServerConnection } from "./runtime/server/registry"
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function useNewSessionCommands(input: {
|
||||
restoreFocus: () => void
|
||||
@@ -21,7 +21,7 @@ export function useNewSessionCommands(input: {
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogCommandPalette } = await import("@/components/dialog-command-palette")
|
||||
const { DialogCommandPalette } = await import("@/shell/commands/dialog")
|
||||
void dialog.show(() => <DialogCommandPalette />)
|
||||
},
|
||||
},
|
||||
+10
-13
@@ -3,20 +3,17 @@ import { getDirectory } from "@opencode-ai/util/path"
|
||||
import { startTransition } from "solid-js"
|
||||
import type { NewSessionComposerAdapter } from "@/composer/adapter"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import {
|
||||
createComposerControls,
|
||||
createComposerModelSelection,
|
||||
createComposerProjectControls,
|
||||
} from "@/composer/selection"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useData, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { createComposerControls, createComposerModelSelection } from "@/composer/selection"
|
||||
import { createComposerProjectControls } from "./project/controller"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { serverName, ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import type { PromptProjectControls } from "./selector"
|
||||
|
||||
export function createComposerProjectControls(props: { draftId: string }) {
|
||||
const servers = useServers()
|
||||
const serverSDK = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const projectServer = () => serverSDK.server
|
||||
const projectServerCtx = useServerCtx(projectServer)
|
||||
const projects = createMemo(() => {
|
||||
if (servers.list.length <= 1) return projectServerCtx().projects.list()
|
||||
return servers.list.flatMap((connection) => {
|
||||
const server = { key: ServerConnection.key(connection), name: serverName(connection) }
|
||||
return global
|
||||
.ensureServerCtx(connection)
|
||||
.projects.list()
|
||||
.map((project) => ({ ...project, server }))
|
||||
})
|
||||
})
|
||||
const selectProject = (worktree: string, serverKey?: string) => {
|
||||
const connection = serverKey
|
||||
? servers.list.find((connection) => ServerConnection.key(connection) === serverKey)
|
||||
: projectServer()
|
||||
if (!connection) return
|
||||
|
||||
const target = global.ensureServerCtx(connection)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(props.draftId, {
|
||||
server: ServerConnection.key(connection),
|
||||
directory: worktree,
|
||||
worktree: undefined,
|
||||
})
|
||||
}
|
||||
const addProject = (title: string, serverKey?: string) => {
|
||||
const connection = serverKey
|
||||
? servers.list.find((connection) => ServerConnection.key(connection) === serverKey)
|
||||
: projectServer()
|
||||
if (!connection) return
|
||||
pickDirectory({
|
||||
server: connection,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory, serverKey)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<PromptProjectControls>(() => ({
|
||||
available: projects(),
|
||||
directory: location().directory,
|
||||
server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
}))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user