Compare commits

..
253 changed files with 2882 additions and 9079 deletions
@@ -153,15 +153,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
// no code. Classified failures such as context overflow keep their runner-owned recovery.
if (
create.mode === "incremental" &&
observation.error.reason._tag === "InvalidRequest" &&
observation.error.reason.classification === undefined
)
return rejected(observation, "retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
@@ -181,7 +172,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
responseID,
request,
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output?.length
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
+1 -2
View File
@@ -655,8 +655,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
// Replayed text is a finished input item, even if generation was cut short.
status: "completed",
status: metadata?.status,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -1,8 +1,7 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -38,10 +37,11 @@ const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
})
const chatRoute = OpenAIChat.route.with({
+2 -5
View File
@@ -115,11 +115,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}
const onAbort = () => {
cleanup()
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
// after cleanup, EventEmitter would throw it as an uncaught exception.
ws.addEventListener("error", () => {}, { once: true })
ws.close(1000)
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
ws.close(1000)
}
const onOpen = () => {
cleanup()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -91,7 +91,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "open-responses",
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "open-responses",
protocol: "openai-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
@@ -1,120 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const model of [
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
]) {
describe(`${model.route.protocol} message replay`, () => {
const key = model.route.providerMetadataKey ?? "openresponses"
it.effect("marks assistant text completed regardless of stored status", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
Message.make({
role: "assistant",
providerMetadata: { [key]: { status } },
content: [
{
type: "text",
text: `Saved ${index}`,
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
},
{
type: "text",
text: `Final ${index}`,
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
},
],
}),
),
Message.make({
role: "user",
content: [{ type: "text", text: "Continue" }],
providerMetadata: { [key]: { status: "incomplete" } },
}),
],
}),
)
expect(prepared.body.input).toEqual([
...[0, 1, 2, 3].flatMap((index) => [
{
type: "message",
role: "assistant",
id: `msg_${index}`,
phase: "commentary",
status: "completed",
content: [{ type: "output_text", text: `Saved ${index}` }],
},
{
type: "message",
role: "assistant",
id: `msg_final_${index}`,
phase: "final_answer",
status: "completed",
content: [{ type: "output_text", text: `Final ${index}` }],
},
]),
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_partial", status: "in_progress" },
},
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_partial",
status: "incomplete",
content: [{ type: "output_text", text: "The next step is" }],
},
},
{
type: "response.incomplete",
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
},
),
),
),
)
expect(response.finishReason.normalized).toBe("length")
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
id: "msg_partial",
status: "completed",
content: [{ type: "output_text", text: "The next step is" }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
})
}
@@ -91,7 +91,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -299,27 +299,23 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
id: `history_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Long." }],
},
{
type: "message",
id: "provider_value/with+symbols",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Opaque." }],
},
{
type: "message",
role: "assistant",
status: "completed",
content: [
{ type: "output_text", text: "No suffix." },
{ type: "output_text", text: "No prefix." },
@@ -860,7 +856,6 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
@@ -171,7 +171,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
@@ -208,7 +208,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -30,7 +30,6 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
@@ -70,34 +69,14 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
},
})
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
const base = baseChannelDriver(message)
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
return {
...base,
observe: (create, frame) =>
base.observe(create, frame).pipe(
Effect.map((observation) =>
observation.type === "provider-failure"
? {
...observation,
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
}
: observation,
),
),
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: base(message),
base: baseChannelDriver(message),
})
}
@@ -406,7 +385,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -581,54 +560,52 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
),
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a tool call from authoritative completed response output", () =>
@@ -682,47 +659,45 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
),
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
@@ -877,53 +852,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(firstRequest, classifyingChannelDriver)
const saved = checkpoint(
yield* first.observe(
yield* first.create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver(
{
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
},
classifyingChannelDriver,
)
// Codex reports a stale previous_response_id as a plain invalid_request_error.
const stale = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
})
const incremental = yield* second.create(saved)
expect(incremental.mode).toBe("incremental")
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
// A full send has no continuation to blame, so the same error stays a provider failure.
const full = yield* second.create(undefined)
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
const overflow = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
})
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -2122,7 +2050,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
@@ -2205,7 +2132,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_commentary",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
@@ -2213,7 +2139,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_final",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
@@ -2221,7 +2146,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_null",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -3352,19 +3276,14 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Before." }],
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -3628,14 +3547,12 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
},
{
type: "message",
id: `message_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "World" }],
},
{
@@ -1,103 +0,0 @@
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
import { SessionBrowserPane } from "../src/session/browser/pane"
export function mountBrowserPane() {
const host = document.createElement("main")
host.dataset.testid = "browser-pane-fixture"
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
document.body.appendChild(host)
function Fixture() {
const [store, setStore] = createStore({
session: "Alpha",
mounted: true,
visible: true,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
url: `https://${name.toLowerCase()}.example/`,
loading: false,
canGoBack: false,
canGoForward: false,
generation: 0,
}))
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
const registrations = new Map<string, BrowserPaneRegistration>(
tabs.map((tab) => [
tab.title,
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
close: () => undefined,
},
]),
)
const browser: ReturnType<typeof createSessionBrowser> = {
available: () => true,
attached: () => !!registrations.get(store.session),
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
}
return (
<>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
<p>Selected session: {store.session}</p>
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
<For each={["Alpha", "Beta", "Empty"]}>
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
{(tab) => (
<div
data-testid={`native-${tab.title}`}
data-visible={!!store.layouts[tab.title]?.visible}
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
>
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
</div>
)}
</For>
</>
)
}
return render(
() => (
<LanguageProvider locale="en">
<UiI18nBridge>
<DialogProvider>
<Fixture />
</DialogProvider>
</UiI18nBridge>
</LanguageProvider>
),
host,
)
}
@@ -1,44 +0,0 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await page.evaluate(async (fixture) => {
const { mountBrowserPane } = await import(fixture)
mountBrowserPane()
}, fixture)
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
})
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Empty", exact: true }).click()
await expect(root.locator("#browser-panel")).toHaveCount(0)
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
@@ -1,27 +0,0 @@
# Session-export load benchmark
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
From `packages/app` in PowerShell:
```powershell
$env:PLAYWRIGHT_BUILD = '1'
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
```
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
@@ -1,227 +0,0 @@
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { stressSessionHref } from "./timeline-test-helpers"
import { startChromeTrace } from "../chrome-trace"
const file = process.env.LAGGY_SESSION_FILE
const session = file
? (JSON.parse(readFileSync(file, "utf8")) as {
info: {
id: string
projectID: string
title: string
model?: { id: string; providerID: string }
location: { directory: string }
time: { created: number; updated: number }
}
messages: SessionMessageInfo[]
})
: undefined
const sourceID = "ses_laggy_benchmark_source"
const sourceMessageID = "msg_laggy_benchmark_source"
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
const viewport = { width: 1440, height: 900 }
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
for (const mode of ["compact", "ungrouped"] as const) {
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
if (!session) return
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
const lastID = session.messages.findLast((message) => message.type === "user")!.id
const lastText = session.messages.findLast(
(message) =>
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
)!
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
const requests: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
if (process.env.LAGGY_HTTP === "1")
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
})
const detail = Object.fromEntries(
timelineCategories.map((category) => [
category,
{
...timelinePresets[2].value[category],
placement: mode === "compact" ? "grouped" : "separate",
},
]),
)
const directory = session.info.location.directory
if (process.env.LAGGY_HTTP !== "1")
await mockOpenCodeServer(page, {
directory,
project: {
id: session.info.projectID,
worktree: directory,
vcs: "git",
name: "session-benchmark",
time: session.info.time,
sandboxes: [],
},
provider: {
all: [
{
id: model.providerID,
name: model.providerID,
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
},
],
connected: [model.providerID],
default: { providerID: model.providerID, modelID: model.id },
},
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
pageMessages: (id, limit, before) => {
if (id !== session.info.id)
return {
items: [
{
id: sourceMessageID,
type: "user",
text: "Benchmark source",
time: { created: session.info.time.created },
},
],
}
if (history === "full") return { items: session.messages }
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
const start = Math.max(0, end - limit)
return {
items: session.messages.slice(start, end),
cursor: start > 0 ? session.messages[start].id : undefined,
}
},
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await page.addInitScript(
({ detail, directory, server, sessionIDs, dirBase64 }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{
detail,
directory,
server: process.env.PLAYWRIGHT_BASE_URL!,
sessionIDs: [sourceID, session.info.id],
dirBase64: base64Encode(directory),
},
)
await page.goto(stressSessionHref(sourceID))
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
expect(requests).toEqual([sourceID])
const startedAt = new Date().toISOString()
const samples = []
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
for (const [iteration, phase] of phases.entries()) {
const before = requests.length
const stopTrace =
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
? await startChromeTrace(page, `laggy-${history}-${mode}`)
: undefined
const result = await measureSessionSwitch(page, {
destinationIDs: session.messages.map((message) => message.id),
sourceIDs: [sourceMessageID],
lastID,
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
requireBottomAnchor: true,
href: stressSessionHref(session.info.id),
switch: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
},
})
await stopTrace?.()
await expectSessionTitle(page, session.info.title)
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
await expect(
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
).toHaveCount(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
if (phase === "warm") expect(requests.length - before).toBe(0)
samples.push({
iteration,
phase,
messageRequests: requests.length - before,
messageResources: await page.evaluate((sessionID) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter(
(entry) =>
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
)
.map((entry) => ({
limit: Number(new URL(entry.name).searchParams.get("limit")),
startMs: entry.startTime - start,
durationMs: entry.duration,
transferBytes: entry.transferSize,
}))
}, session.info.id),
...result,
})
if (iteration === phases.length - 1) {
mkdirSync(output, { recursive: true })
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
break
}
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
expect(errors).toEqual([])
const result = {
pair: testInfo.repeatEachIndex,
startedAt,
mode,
history,
file,
messages: session.messages.length,
viewport,
browser: page.context().browser()!.version(),
detail,
samples,
}
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
report(
{ samples },
{
mode,
sampling: "cold/warm pair in one browser context",
pair: testInfo.repeatEachIndex,
messages: session.messages.length,
viewport,
data: history === "full" ? "full exported history" : "paginated exported history",
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
},
)
})
}
@@ -1,63 +0,0 @@
export {}
type Pair = {
mode: "compact" | "ungrouped"
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
}
const directory = Bun.argv[2]
if (!directory) throw new Error("Pass the directory containing session-load pairs")
const pairs = await Promise.all(
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
const cold = pair.samples.find((sample) => sample.phase === "cold")
const warm = pair.samples.find((sample) => sample.phase === "warm")
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
throw new Error(`Expected a completed cold/warm pair in ${file}`)
return {
mode: pair.mode,
cold: cold.firstCorrectObservedMs,
warm: warm.firstCorrectObservedMs,
requests: warm.messageRequests,
}
}),
)
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
const result = ["compact", "ungrouped"].flatMap((mode) => {
const selected = pairs.filter((pair) => pair.mode === mode)
if (!selected.length) return []
return [
{
mode,
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
},
]
})
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
console.table(
result.map((row) => ({
mode: row.mode,
pairs: row.cold.n,
coldMedianMs: Math.round(row.cold.median * 10) / 10,
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
warmMedianMs: Math.round(row.warm.median * 10) / 10,
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
warmMaxMs: Math.round(row.warm.max * 10) / 10,
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
})),
)
function stats(values: number[]) {
const sorted = values.toSorted((left, right) => left - right)
return {
n: sorted.length,
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
min: sorted[0],
max: sorted.at(-1)!,
}
}
@@ -16,11 +16,6 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
@@ -22,10 +22,6 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branches?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
@@ -48,28 +44,10 @@ test("selects a base branch for a new workspace", async ({ page }) => {
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await expect(search).toBeFocused()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(search).toBeFocused()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(selected).toBeFocused()
await page.keyboard.press("Enter")
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await page.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api", "main", "origin/release"])
})
@@ -1,129 +0,0 @@
import { expect, test } from "@playwright/test"
import type { ConfigEntry } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/configured-lsp"
const entries: ConfigEntry[] = [
{
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
},
},
},
{
type: "document",
path: `${directory}/opencode.jsonc`,
info: {
lsp: {
typescript: { disabled: true },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_configured_lsp",
canonical: directory,
name: "Configured LSP project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
})
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/config",
async (route) => {
await ready.promise
await route.fulfill({ json: entries })
},
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
ready.resolve()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
await expect(typescript).toContainText("Disabled in config")
await expect(typescript).toContainText(".ts, .tsx")
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
"Enabled in config",
)
await expect(panel.getByRole("switch")).toHaveCount(0)
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
})
for (const lsp of [true, false]) {
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp } }],
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
).toBeVisible()
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
})
}
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
const state = { fail: true }
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
status: state.fail ? 404 : 200,
json: state.fail ? {} : entries,
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
state.fail = false
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
})
@@ -20,7 +20,6 @@ test("session settings use the remote server context", async ({ page }) => {
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
preferencesUnavailable: true,
})
await configureServers(page)
@@ -37,8 +36,6 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeHidden()
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -66,21 +63,9 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("tab")).toHaveText([
"127.0.0.1:4097",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
])
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -88,7 +73,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -326,7 +311,6 @@ type MockServerOptions = {
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
preferencesUnavailable?: boolean
}
async function mockServers(
@@ -418,11 +402,6 @@ async function mockServers(
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/config/shell")
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/websearch/provider")
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -238,9 +238,16 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
})
const chatBounds = chat.getBoundingClientRect()
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
const summaryBounds = document
.querySelector('[data-session-title] button[aria-label="Session details"]')!
.getBoundingClientRect()
return {
row: row.getBoundingClientRect().width,
panelWidth: panelBounds.width,
timelineControlInset:
getComputedStyle(row).direction === "rtl"
? summaryBounds.left - chatBounds.left
: chatBounds.right - summaryBounds.right,
gap:
getComputedStyle(row).direction === "rtl"
? chatBounds.left - panelBounds.right
@@ -250,6 +257,8 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
}
}, progress)
expect(geometry.gap).toBeCloseTo(8, 1)
// Reserve the fixed toggle's 28px width, the 8px control gap, and the 12px header inset.
expect(geometry.timelineControlInset).toBeCloseTo(48, 1)
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
if (progress === 0.25) {
expect(geometry.contentOpacity).toBeGreaterThan(0)
@@ -27,12 +27,8 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
const add = settings.getByRole("button", { name: "Add server" })
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
await expect(add).toHaveCSS("opacity", "0")
await group.hover()
await expect(add).toHaveCSS("opacity", "1")
await add.click()
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
@@ -198,15 +198,14 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const settingsScreen = page.getByTestId("settings-screen")
await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible()
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(project.name)
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
await expect(menu).toBeHidden()
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
@@ -273,13 +272,12 @@ for (const state of ["closed", "unopened"] as const) {
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const settingsScreen = page.getByTestId("settings-screen")
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(fixture.project.name)
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
@@ -1,74 +0,0 @@
import { expect, test } from "@playwright/test"
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
const release = Promise.withResolvers<void>()
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
await release.promise
return route.fallback()
})
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Observe optimistic prompt spacing.")
await expect
.poll(() =>
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
const root = element.parentElement!
return root.scrollHeight - root.clientHeight - root.scrollTop
}),
)
.toBe(0)
const observation = await page.evaluateHandle(() => {
const frames: { prompt?: number; working: boolean }[] = []
let frame = 0
const sample = () => {
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
row.textContent?.includes("Observe optimistic prompt spacing."),
)
frames.push({
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
working: !!document.querySelector('[data-component="session-working"]'),
})
frame = requestAnimationFrame(sample)
}
frame = requestAnimationFrame(sample)
return {
stop: () => {
cancelAnimationFrame(frame)
return frames
},
}
})
const requested = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
)
try {
await editor.press("Enter")
await requested
const prompt = page
.locator('[data-timeline-row="UserMessage"]')
.filter({ hasText: "Observe optimistic prompt spacing." })
await expect(prompt).toBeInViewport()
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
await expect
.poll(() =>
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
const root = element.parentElement!
return root.scrollHeight - root.clientHeight - root.scrollTop
}),
)
.toBe(0)
const frames = await observation.evaluate((value) => value.stop())
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
expect(positions.length).toBeGreaterThan(0)
expect(new Set(positions).size).toBe(1)
} finally {
release.resolve()
await observation.dispose()
}
})
@@ -1,315 +0,0 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
})
test("focuses Find in the selected cached timeline", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
const search = page.locator('[data-component="timeline-search-bar"] input')
await expect(search).toBeFocused()
await page.keyboard.type("History")
await expect(search).toHaveValue("History")
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.press("Escape")
await expect(search).toHaveCount(0)
})
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
const destination = "C:/OpenCode/OtherProject"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const reads: string[] = []
const output = { text: "Initial shell output\n" }
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) =>
session.id === fixture.targetID ? { ...session, directory: destination } : session,
),
pageMessages: (id) => ({
items:
id === fixture.sourceID
? ([
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
{
id: "msg_workspace_shell",
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2 },
content: [
{
type: "tool",
id: "call_workspace_shell",
name: "shell",
time: { created: 2 },
state: {
status: "running",
input: { command: "run checks" },
metadata: { shellID: "sh_workspace_source" },
},
},
],
},
] satisfies SessionMessageInfo[])
: [],
}),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")!
reads.push(directory)
if (directory !== fixture.directory)
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
return route.fulfill({
json: {
location: { directory },
data: {
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: output.text.length,
size: output.text.length,
truncated: false,
},
},
})
})
await installStressSessionTabs(page)
await page.addInitScript(
(detail) =>
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
},
}),
),
timelinePresets[2].value,
)
await page.goto(stressSessionHref(fixture.sourceID))
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
output.text += "Output after returning\n"
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
expect(reads.length).toBeGreaterThan(1)
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
} finally {
release.resolve()
}
})
test("loads the transcript code font before opening rich history", async ({ page }) => {
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
release.resolve()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
} finally {
release.resolve()
}
})
for (const grouped of [true, false]) {
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
page,
}) => {
const events: OpenCodeEvent[] = []
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((id) => [
id,
[
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [
{
type: "tool",
id: `tool_${id}`,
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: `echo ${id}` },
metadata: {},
content: [{ type: "text", text: `Output for ${id}` }],
},
},
{ type: "text", text: `Answer for ${id}` },
],
},
] satisfies SessionMessageInfo[],
]),
)
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: messages[id] ?? [] }),
events: () => events.splice(0),
})
await installStressSessionTabs(page)
await page.addInitScript(
({ grouped, detail }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: {
...detail,
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
},
},
}),
)
},
{ grouped, detail: timelinePresets[2].value },
)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
if (grouped)
await page
.locator(
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
)
.click()
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
await expect(shell).toHaveCount(0)
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
events.push({
id: "evt_cached_text",
created: 4,
type: "session.text.ended",
location: { directory: fixture.directory },
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
data: {
sessionID: fixture.sourceID,
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
ordinal: 0,
text: "Updated while inactive",
},
})
await page.setViewportSize({ width: 900, height: 650 })
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
await expect
.poll(() =>
page
.locator("[data-timeline-key]")
.evaluateAll((rows) =>
rows.every(
(row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
),
),
)
.toBe(true)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 40
const messagePageSize = 20
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
])
expect(roots).toEqual([])
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
for (const window of ["assistant-only", "mixed"] as const) {
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
// Both 20-message pages begin with an assistant; only page three supplies its parent.
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
const id = `msg_hydration_${index}`
const time = { created: 1700000000000 + index * 1_000 }
if (index === 0 || (window === "mixed" && index === 59))
if (index === 0 || (window === "mixed" && index === 39))
return { id, type: "user", time, text: `Prompt ${index}` }
return {
id,
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
time: { ...time, completed: time.created + 500 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
}
})
const gates = [21, 1].map((index) => ({
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
await gate.release.promise
},
pageMessages: (_, limit, before) => {
expect(limit).toBe(before ? 20 : 40)
expect(limit).toBe(20)
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
})
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
const markdown = tail.locator('[data-component="markdown"]')
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
const viewport = page.locator(".scroll-view__viewport", { has: tail })
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
})
const expectReadyTail = async () => {
await expect(content).toHaveCSS("visibility", "visible")
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
if (window === "mixed")
await expect(
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
).toBeInViewport()
const original = await markdown.elementHandle()
@@ -7,6 +7,7 @@ import {
compactionFailed,
compactionStarted,
directory,
event,
session,
sessionID,
setupTimeline,
@@ -86,13 +87,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders compaction progress, summary, and outcome in order", async ({ page }) => {
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
sessionStatus: { [sessionID]: { type: "busy" } },
})
await timeline.send(
@@ -104,15 +104,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await expect(page.locator('[data-component="session-working"]')).toHaveCount(0)
await page.setViewportSize({ width: 480, height: 900 })
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeInViewport()
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
@@ -122,8 +114,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await timeline.send(
compactionEnded({
@@ -135,18 +125,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await expect
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const completed = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
return !!summary && !!completed && completed.y >= summary.y + summary.height
})
.toBe(true)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
@@ -168,10 +146,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compaction failed", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(failed.getByRole("status")).toHaveCount(0)
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
@@ -189,48 +164,11 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compaction cancelled", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(cancelled.getByRole("status")).toHaveCount(0)
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("shows an interrupted outcome when stopping automatic compaction", async ({ page }) => {
const timeline = await setupTimeline(page, {
sessionMessages: [user, assistant(true)],
sessionStatus: { [sessionID]: { type: "busy" } },
})
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction).toContainText("Partial automatic summary.")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
)
await page.getByRole("button", { name: "Stop", exact: true }).click()
await request
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
)
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction interrupted", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction failed", { exact: true })).toHaveCount(0)
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(compaction).not.toContainText("Partial automatic summary.")
await expect(compaction).not.toContainText("Compaction was interrupted")
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
@@ -1,68 +0,0 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
const sessions = Array.from({ length: 5 }, (_, index) => ({
...fixture.sessions[0]!,
id: `ses_workspace_cycle_${index}`,
directory: `${fixture.directory}/worktree-${index}`,
title: `Workspace session ${index}`,
}))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions,
pageMessages: (id) => ({
items: [
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [{ type: "text", text: `Answer for ${id}` }],
},
] satisfies SessionMessageInfo[],
}),
events: () => events.splice(0),
})
await page.route("**/api/location?*", (route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
},
}),
)
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
await page.goto(stressSessionHref(sessions[0]!.id))
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
const active = sessions[0]!
events.push({
id: "evt_workspace_cycle_update",
created: 4,
type: "session.text.ended",
location: { directory: active.directory },
durable: { aggregateID: active.id, seq: 0, version: 1 },
data: {
sessionID: active.id,
assistantMessageID: `msg_assistant_${active.id}`,
ordinal: 0,
text: "Still receiving updates",
},
})
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
})
@@ -13,20 +13,11 @@ test.beforeEach(async ({ page }) => {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
icon: {
color: "orange",
override:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E",
},
commands: { start: "echo setup" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
preferences: { shell: "zsh", websearch: { provider: "exa" } },
shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }],
websearchProviders: [{ id: "exa", name: "Exa" }],
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
@@ -65,131 +56,6 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveCount(0)
for (const name of ["Projects", "Worktrees", "Providers", "Models", "Extensions"]) {
await settings.getByRole("tab", { name, exact: true }).click()
await expect(settings.locator('[data-action="settings-server-select"]')).toHaveCount(0)
}
await settings.getByRole("tab", { name: "Server", exact: true }).click()
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
await expect(settings.getByText("Third-party search", { exact: true })).toBeVisible()
await expect(settings.getByText("zsh", { exact: true })).toBeVisible()
await expect(settings.getByText("Exa", { exact: true })).toBeVisible()
await settings.getByText("Exa", { exact: true }).click()
const updated = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/config/preferences",
)
await page.getByRole("option", { name: "Any", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ websearch: { provider: "random" } })
})
test("server details tolerate unavailable preference endpoints", async ({ page }) => {
await page.route(
(url) =>
url.pathname === "/api/config/preferences" ||
url.pathname === "/api/config/shell" ||
url.pathname === "/api/websearch/provider",
(route) => route.fulfill({ status: 404, json: {} }),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Server", exact: true }).click()
const connection = settings.locator('[data-component="settings-server-connection"]')
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(page.getByText("Server request failed", { exact: true })).toHaveCount(0)
})
test("project settings open as a nested autosaving view", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Settings demo", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
const saved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await name.fill("Renamed project")
await name.blur()
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
const scriptSaved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.fill("bun install")
await startup.blur()
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
})
test("clearing project fields sends explicit removal values", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
await expect(startup).toHaveValue("echo setup")
const scriptSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.clear()
await startup.blur()
const scriptResponse = await scriptSaved
expect(scriptResponse.ok()).toBe(true)
expect(scriptResponse.request().postDataJSON()).toEqual({ commands: { start: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const icon = settings.getByRole("button", { name: "Project icon", exact: true })
await expect(icon.locator("img")).toHaveCount(1)
const iconSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await icon.hover()
await icon.click()
const iconResponse = await iconSaved
expect(iconResponse.ok()).toBe(true)
expect(iconResponse.request().postDataJSON()).toEqual({ icon: { color: "orange", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const color = settings.getByRole("button", { name: "Select orange color", exact: true })
await expect(color).toHaveAttribute("aria-pressed", "true")
const colorSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await color.click()
const colorResponse = await colorSaved
expect(colorResponse.ok()).toBe(true)
expect(colorResponse.request().postDataJSON()).toEqual({ icon: { color: "", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
@@ -50,12 +50,12 @@ for (const viewport of [
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Server",
"Experimental",
"About",
]) {
@@ -1,83 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const colorScheme of ["light", "dark"] as const) {
test.describe(colorScheme, () => {
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
id: `project-${index}`,
name,
canonical: `/projects/${name}`,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}))
await mockOpenCodeServer(page, {
directory: "/projects/rebase",
project: projects[0],
sessions: [],
pageMessages: () => ({ items: [] }),
provider: { all: [], connected: [], default: {} },
})
await page.route("**/api/project", (route) =>
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
)
await page.addInitScript((projects) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: projects.map((project) => ({ worktree: project.canonical, expanded: true })) },
}),
)
}, projects)
await page.goto("/")
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeEnabled()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
const panel = settings.getByRole("tabpanel")
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready)
for (const width of [1280, 1050, 960, 720, 600]) {
await page.setViewportSize({ width, height: 720 })
await page.mouse.move(0, 0)
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
// Raised cards paint a half-pixel border outside their box. The scrollport
// must leave room for that border and the soft shadow on both sides.
await expect
.poll(() =>
panel.getByText("rebase", { exact: true }).evaluate((label) => {
const row = label.parentElement!.parentElement!
const bounds = row.getBoundingClientRect()
const clips = []
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowX === "visible") continue
const clip = parent.getBoundingClientRect()
clips.push(bounds.left - clip.left, clip.right - bounds.right)
}
return Math.min(...clips)
}),
)
.toBeGreaterThanOrEqual(4)
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((el) => el.clientWidth))
}
await page.setViewportSize({ width: 1280, height: 720 })
await panel.getByText("rebase", { exact: true }).hover()
await panel.getByText("rebase", { exact: true }).click()
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("rebase")
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 260 })
await panel.getByText("rebase", { exact: true }).hover()
await page.mouse.wheel(0, 400)
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
})
})
}
@@ -1,48 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 1280, height: 900 } })
for (const mode of ["failed", "stopped", "ready"] as const) {
test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/repo",
project: {
id: "proj_wsl_settings",
canonical: "/repo",
name: "WSL project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled()
const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true })
await expect(ubuntu).toHaveCount(1)
await ubuntu.click()
await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible()
const connection = settings.locator('[data-component="settings-server-connection"]')
if (mode !== "ready") {
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled()
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
}
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled()
})
}
@@ -1,219 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/workspaces-prefetch"
const sandboxes = [`${directory}/first`, `${directory}/second`]
const project = {
id: "proj_workspaces_prefetch",
canonical: directory,
name: "Prefetch project",
sandboxes,
time: { created: 1, updated: 1 },
}
const other = { ...project, id: "proj_other", name: "Other project", canonical: "/repo/other", sandboxes: [] }
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: "ses_workspaces_cached",
title: "Cached worktree session",
projectID: project.id,
directory: sandboxes[0],
time: { created: 1, updated: 1 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await expect(page.getByText("Cached worktree session", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
for (const interaction of ["hover", "focus"] as const) {
test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const calls: string[] = []
const sessions: string[] = []
await page.route(
(url) => url.pathname === "/api/project",
(route) => route.fulfill({ json: [project, other] }),
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
await inventory.promise
await route.fallback()
},
)
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname === "/api/session" && url.searchParams.has("directory"))
sessions.push(url.searchParams.get("directory")!)
})
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
await expect(worktrees).toBeEnabled()
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await worktrees[interaction]()
await requested
await expect(worktrees).toHaveAttribute("aria-selected", "false")
await expect.poll(() => calls).toEqual([directory])
expect(sessions).toEqual([])
if (interaction === "hover") {
const finished = page.waitForEvent(
"requestfinished",
(request) => new URL(request.url()).pathname === "/api/worktree",
)
inventory.resolve()
await finished
}
await worktrees.click()
await expect(worktrees).toHaveAttribute("aria-selected", "true")
inventory.resolve()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
expect(calls).toEqual([directory])
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
})
}
for (const nested of [false, true]) {
test(`${nested ? "nested" : "root"} server Worktrees hover only prefetches metadata`, async ({ page }) => {
if (nested) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [
{ type: "http", displayName: "Settings server", http: { url: server } },
{ type: "http", displayName: "Other server", http: { url: "http://127.0.0.1:4097" } },
],
}),
)
}, server)
await page.reload()
await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click()
}
const calls = { projects: 0, worktrees: [] as string[] }
await page.route(
(url) => url.pathname === "/api/project",
async (route) => {
calls.projects += 1
await route.fulfill({ json: [project, other] })
},
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
calls.worktrees.push(requested)
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
await route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
await expect(worktrees).toBeEnabled()
const fetched = page.waitForEvent(
"requestfinished",
(request) => new URL(request.url()).pathname === "/api/project",
)
await worktrees.hover()
await fetched
await worktrees.focus()
await expect(worktrees).toHaveAttribute("aria-selected", "false")
expect(calls).toEqual({ projects: 1, worktrees: [] })
await worktrees.click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
expect(calls.projects).toBe(1)
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
})
}
test("cached sessions render while directory sessions load without treating unknown rows as empty", async ({
page,
}) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/session" && url.searchParams.has("directory"),
async (route) => {
await ready.promise
await route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/session" && url.searchParams.has("directory")
})
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
const empty = settings
.locator(".settings-workspaces-row")
.filter({ has: page.getByLabel(sandboxes[1], { exact: true }) })
await expect(empty).toContainText("Loading messages")
await settings.getByRole("button", { name: "More options", exact: true }).click()
await expect(page.getByRole("menuitem", { name: "Delete worktrees without sessions", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
ready.resolve()
await expect(empty).toContainText("0 sessions")
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
})
test("project deletion updates the cached server-wide inventory", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: [
{ directory },
...sandboxes
.filter((directory) => !removed.has(directory))
.map((directory) => ({ directory, strategy: "git" })),
],
})
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("button", { name: "Delete “second”?", exact: true }).click()
await page
.getByRole("dialog", { name: "Delete “second”?", exact: true })
.getByRole("button", { name: "Delete worktree", exact: true })
.click()
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
await expect(settings.getByLabel(sandboxes[1], { exact: true })).toHaveCount(0)
})
-16
View File
@@ -70,23 +70,7 @@ const Group = HttpApiGroup.make("mock")
.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.patch("projectUpdate", "/api/project/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
.add(
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json }))
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
+1 -28
View File
@@ -9,9 +9,6 @@ export interface MockServerConfig {
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
preferences?: Record<string, unknown>
shells?: unknown[]
websearchProviders?: unknown[]
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@@ -83,7 +80,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
let keepalive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
@@ -93,15 +89,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
// Match the real server's idle stream so long scenarios do not
// trigger the client's 45-second stall watchdog and reload history.
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
@@ -111,7 +103,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
cancel() {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === own) state.controller = undefined
},
})
@@ -188,14 +179,13 @@ export function createMockServerHandler(config: MockServerConfig) {
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"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))
const preferences = { current: config.preferences ?? {} }
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
@@ -273,29 +263,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectUpdate: (ctx) => {
const project = config.project as { canonical?: string }
return Effect.succeed({
...project,
...ctx.payload,
id: ctx.params.projectID,
canonical: project.canonical ?? config.directory,
})
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
configPreferences: () => Effect.succeed(preferences.current),
configUpdatePreferences: (ctx) =>
Effect.sync(() => {
preferences.current = { ...preferences.current, ...ctx.payload }
return preferences.current
}),
configShells: () => Effect.succeed(config.shells ?? []),
websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
@@ -1,135 +0,0 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
import { ServerConnection } from "../../src/runtime/server/registry"
import { useWslServers } from "../../src/servers/wsl/context"
import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types"
export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: "/settings", replace: true, scroll: false })
render(() => {
const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({
calls: [],
state: {
runtime: { available: true, version: "2", error: null },
installed: [],
online: [],
distroProbes: {},
pendingRestart: false,
job: null,
servers: [
{
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
runtime:
input.mode === "ready"
? { kind: "ready", url: input.server, password: null }
: input.mode === "failed"
? { kind: "failed", message: "WSL failed to start" }
: { kind: "stopped" },
},
],
opencodeChecks: {
Ubuntu: {
distro: "Ubuntu",
resolvedPath: "/usr/bin/opencode",
version: "old",
expectedVersion: "current",
matchesDesktop: false,
error: null,
},
},
},
})
const listeners = new Set<(event: WslServersEvent) => void>()
const publish = () =>
listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) }))
const unused = async () => {
throw new Error("Unexpected fixture action")
}
const wsl: WslServersPlatform = {
getState: async () => structuredClone(unwrap(store.state)),
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
probeRuntime: unused,
refreshDistros: unused,
installWsl: unused,
installDistro: unused,
probeAddable: unused,
openTerminal: unused,
addServer: unused,
async installOpencode(distro) {
setStore("calls", (calls) => [...calls, `update:${distro}`])
setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true })
publish()
},
async startServer(id) {
setStore("calls", (calls) => [...calls, `start:${id}`])
setStore("state", "servers", (server) => server.config.id === id, "runtime", {
kind: "ready",
url: input.server,
password: null,
})
publish()
},
async removeServer(id) {
setStore("calls", (calls) => [...calls, `remove:${id}`])
setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id))
publish()
},
}
const platform: Platform = {
platform: "desktop",
os: "windows",
windowID: "settings-wsl-test",
openExternal: () => undefined,
openDirectoryPickerDialog: async () => null,
notify: async () => undefined,
restart: unused,
wslServers: wsl,
}
function Interface() {
const wsl = useWslServers()
const servers = createMemo<ServerConnection.Any[]>(() => [
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] =>
item.runtime.kind === "ready"
? [
{
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
displayName: item.config.distro,
http: { url: item.runtime.url },
},
]
: [],
),
])
return (
<Show when={wsl.data}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
<output aria-label="WSL actions">{store.calls.join(",")}</output>
<Interface />
</AppBaseProviders>
</PlatformProvider>
)
}, root)
}
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module">
import { mount } from "./settings-wsl.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), mode: query.get("mode") })
</script>
</body>
</html>
-1
View File
@@ -40,7 +40,6 @@ export default defineConfig({
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_TEST_FIXTURES: "1",
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
+9 -21
View File
@@ -88,12 +88,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -126,15 +122,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -326,7 +316,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -363,8 +354,7 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
@@ -394,9 +384,7 @@ async function sendPrompt(
},
},
}
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
@@ -1,7 +1,6 @@
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useServerActionsController } from "@/servers/registry/controller"
import { useSettingsCommand } from "@/settings/command"
import { useSettingsSurface } from "@/settings/surface"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
@@ -28,7 +27,6 @@ export function createHomeProjectsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const openSettings = useSettingsCommand()
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = useSshAuthenticate()
@@ -133,9 +131,8 @@ export function createHomeProjectsController(home: HomeController) {
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+2 -12
View File
@@ -1,8 +1,7 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsSurface } from "@/settings/surface"
import { useSettingsDialog } from "@/settings/command"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
@@ -17,19 +16,10 @@ export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const openWorkspaces = useSettingsDialog("workspaces")
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -242,7 +242,14 @@ export function PromptWorkspaceSelector(props: {
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
@@ -251,14 +258,7 @@ export function PromptWorkspaceSelector(props: {
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault()
// Kobalte defers its list autofocus until after the focus scope opens.
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
}}
>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
+2 -2
View File
@@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => {
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
@@ -162,7 +162,7 @@ export const DialogManageModels: Component = () => {
</Switch>
</div>
<Show when={expanded()}>
<SettingsList variant="catalog">
<SettingsList>
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
@@ -162,12 +162,12 @@ const ModelList: Component<{
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList variant="catalog">
<SettingsList>
<For each={group.items}>
{(item) => (
<button
-29
View File
@@ -448,7 +448,6 @@ export const dict = {
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.menu.remove": "Remove",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.current": "Current Server",
@@ -997,8 +996,6 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.backToApp": "Back to app",
"settings.backToSettings": "Back to settings",
"settings.backToProjects": "Back to projects",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
@@ -1036,10 +1033,6 @@ export const dict = {
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.server.description": "Manage this servers connection and preferences",
"settings.server.section.connection": "Connection",
"settings.server.preferences.websearch.title": "Third-party search",
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
@@ -1057,36 +1050,14 @@ export const dict = {
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.name.title": "Project name",
"project.settings.name.description": "The name shown for this project throughout OpenCode",
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
"project.settings.color.description": "Used for the project icon when no custom image is set",
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
"project.settings.extensions.shared": "Shared with all projects",
"project.settings.extensions.empty.mcps.title": "No MCPs yet",
"project.settings.extensions.empty.mcps.description": "MCPs available to OpenCode will appear here",
"project.settings.extensions.empty.plugins.title": "No plugins yet",
"project.settings.extensions.empty.plugins.description": "Plugins available to OpenCode will appear here",
"project.settings.extensions.empty.skills.title": "No skills yet",
"project.settings.extensions.empty.skills.description": "Skills available to OpenCode will appear here",
"project.settings.extensions.lsp.detected": "Detected language servers",
"project.settings.extensions.lsp.description": "Auto-detected from file types",
"project.settings.extensions.lsp.configured": "Configured language servers",
"project.settings.extensions.lsp.status.enabled": "Enabled in config",
"project.settings.extensions.lsp.status.disabled": "Disabled in config",
"project.settings.extensions.lsp.empty.title": "No language servers configured",
"project.settings.extensions.lsp.empty.description": "Language servers configured for this project will appear here",
"project.settings.extensions.lsp.disabled.title": "Language servers disabled",
"project.settings.extensions.lsp.disabled.description": "LSP is disabled in this projects configuration",
"project.settings.extensions.lsp.loadFailed": "Could not load language server configuration",
"project.settings.extensions.lsp.retry": "Retry",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance",
@@ -34,27 +34,6 @@ function setup(input?: {
}
describe("createRequestQueue", () => {
test("starts a free slot before the caller continues its synchronous work", async () => {
const input = setup()
const response = input.queue.fetch("http://server/api/session")
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/session"])
expect(input.queue.inflight()).toBe(1)
input.pending[0]!.resolve()
await response
expect(input.queue.inflight()).toBe(0)
})
test("releases a free slot without sending an already-aborted request", async () => {
const input = setup()
const controller = new AbortController()
controller.abort()
await expect(input.queue.fetch("http://server/api/session", { signal: controller.signal })).rejects.toBeInstanceOf(
DOMException,
)
expect(input.pending).toHaveLength(0)
expect(input.queue.inflight()).toBe(0)
})
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
@@ -130,10 +109,12 @@ describe("createRequestQueue", () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
@@ -84,23 +84,17 @@ export function createRequestQueue(input: {
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) => {
// A free slot must start fetch before the caller's synchronous UI work.
// Awaiting an already-resolved promise postpones that dispatch until after it.
if (canStart(entry)) {
inflight.add(entry)
return
}
return new Promise<void>((resolve) => {
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
}
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
@@ -109,8 +103,7 @@ export function createRequestQueue(input: {
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
const queued = acquire(entry)
if (queued) await queued
await acquire(entry)
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
+28 -4
View File
@@ -1,5 +1,6 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -15,8 +16,6 @@ import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -26,8 +25,24 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
() => server.list,
() => true,
)
const [store, setStore] = createStore({
settings: {
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const models = createGlobalModels()
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
})
createEffect(() => {
const conn = settingsServer()
const key = conn ? ServerConnection.key(conn) : undefined
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
@@ -67,6 +82,17 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
list: () => server.list,
health: serverHealth,
},
settings: {
server: {
get key() {
return store.settings.serverKey
},
selected: settingsServer,
set(key: ServerConnection.Key) {
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
},
},
},
models,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
@@ -105,12 +131,10 @@ function createServerController(
projects: ReturnType<typeof createServerProjects>,
) {
const language = useLanguage()
const settings = useSettings()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
api: () => sdk.api,
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
+3 -8
View File
@@ -25,15 +25,11 @@ type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
onSave?: (server: ServerConnection.Http) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const form = createFormController({
onSelect: (server) => {
props.onSave?.(server)
dialog.close()
},
onSelect: () => dialog.close(),
})
const [opened, setOpened] = createSignal(false)
@@ -137,7 +133,7 @@ export const DialogServer: Component<{
)
}
function createFormController(options: { onSelect?: (server: ServerConnection.Http) => void } = {}) {
function createFormController(options: { onSelect?: () => void } = {}) {
const platform = usePlatform()
const server = useServers()
const tabs = useTabs()
@@ -224,14 +220,13 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
if (original?.type === "http") {
if (normalized === original.http.url) add(connection)
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
options.onSelect?.(connection)
reset()
return
}
reset()
add(connection)
options.onSelect?.(connection)
options.onSelect?.()
},
}))
@@ -1,24 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import { sortServerConnections } from "./controller"
const server = (url: string): ServerConnection.Http => ({ type: "http", http: { url } })
describe("sortServerConnections", () => {
test("places the default first and preserves health and insertion ordering", () => {
const first = server("http://first")
const offline = server("http://offline")
const preferred = server("http://preferred")
const unknown = server("http://unknown")
const result = sortServerConnections({
servers: [first, offline, preferred, unknown],
health: {
[ServerConnection.key(first)]: { healthy: true },
[ServerConnection.key(offline)]: { healthy: false },
},
defaultKey: ServerConnection.key(preferred),
})
expect(result).toEqual([preferred, first, unknown, offline])
})
})
+22 -30
View File
@@ -1,8 +1,10 @@
import { useNavigate } from "@solidjs/router"
import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { type ServerHealth } from "@/runtime/server/health"
import { showToast } from "@/shell/notifications/toast"
@@ -47,27 +49,6 @@ function useDefaultServer() {
}
}
export function sortServerConnections(input: {
servers: ServerConnection.Any[]
health: Record<string, ServerHealth | undefined>
defaultKey: ServerConnection.Key | null
}) {
const order = new Map(input.servers.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return input.servers.slice().sort((a, b) => {
const preferred =
Number(ServerConnection.key(b) === input.defaultKey) - Number(ServerConnection.key(a) === input.defaultKey)
if (preferred !== 0) return preferred
const health = rank(input.health[ServerConnection.key(a)]) - rank(input.health[ServerConnection.key(b)])
if (health !== 0) return health
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
export function useServerActionsController() {
const server = useServers()
const ssh = useSsh()
@@ -97,8 +78,8 @@ export function useServerActionsController() {
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
},
isHidden: (key: ServerConnection.Key) => server.isHidden(key),
setHidden: (key: ServerConnection.Key, hidden: boolean) => server.setHidden(key, hidden),
isHidden: server.isHidden,
setHidden: server.setHidden,
},
}
}
@@ -108,16 +89,27 @@ export type ServerActionsController = ReturnType<typeof useServerActionsControll
export function useServerCollectionController() {
const server = useServers()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => server.list)
const sorted = createMemo(() =>
sortServerConnections({
servers: items(),
health: global.servers.health,
defaultKey: actions.defaults.key(),
}),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
@@ -45,7 +45,7 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
remove: language.t("dialog.server.menu.remove"),
delete: language.t("dialog.server.menu.delete"),
hide: language.t("dialog.server.menu.hide"),
show: language.t("dialog.server.menu.show"),
}
@@ -106,7 +106,7 @@ export const ServerRowMenuView: Component<{
</Show>
<Show when={props.canRemove}>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.remove}</Menu.Item>
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
</Show>
</Menu.Group>
</Menu.Content>
+9 -38
View File
@@ -16,14 +16,13 @@ import { showToast } from "@/shell/notifications/toast"
import { DialogAddWslServer } from "./dialog"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
import type { WslServerItem } from "./types"
import { DialogSsh } from "../ssh/dialog"
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function AddServerMenu(props: { onAddServer: () => void; compact?: boolean }) {
export function AddServerMenu(props: { onAddServer: () => void }) {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
@@ -34,41 +33,15 @@ export function AddServerMenu(props: { onAddServer: () => void; compact?: boolea
<Show
when={platform.wslServers || platform.sshServers}
fallback={
<Show
when={props.compact}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
}
>
<IconButton
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
onClick={props.onAddServer}
/>
</Show>
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
}
>
<Menu gutter={4} modal={false} placement="bottom-end">
<Show
when={props.compact}
fallback={
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
/>
</Show>
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
@@ -99,7 +72,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
export function WslServerSettings(props: {
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: Accessor<readonly WslServerItem[]>
servers: ReturnType<typeof useFilteredWslServers>
}) {
const platform = usePlatform()
const language = useLanguage()
@@ -187,9 +160,7 @@ export function WslServerSettings(props: {
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item disabled={request.isPending} onSelect={() => remove(key)}>
{language.t("dialog.server.menu.remove")}
</Menu.Item>
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
+1 -7
View File
@@ -108,13 +108,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
}
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
// that owned the native view, rather than reading the destination's handle.
onCleanup(() => current?.setLayout())
}),
)
createEffect(
on(
[
@@ -147,6 +140,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
registration()?.setLayout()
})
return (
@@ -6,7 +6,7 @@ import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode/ui/tooltip"
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
export function SessionHeader() {
const language = useLanguage()
const settings = useSettings()
@@ -21,7 +21,8 @@ export function SessionHeader(props: { reserveReviewToggle: boolean }) {
</Tooltip>
</Show>
</TitlebarRight>
<Show when={isDesktop() && props.reserveReviewToggle}>
{/* Keep the fixed toggle's slot mounted throughout panel motion. */}
<Show when={isDesktop()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
+30 -41
View File
@@ -34,7 +34,6 @@ import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -220,45 +219,6 @@ export function SessionScreen(props: { session: SessionModel }) {
</Show>
)
const timelineView = createTimelineCache(
session,
(source, active) => (
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
session={source}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
<TimelineSearchBar controller={timelineSearch} />
</Show>
}
/>
),
() => conversationVisible() && messagesReady(),
)
const sessionPanelContent = () => (
<>
<ComposerDropzone
@@ -298,7 +258,36 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={isDesktop() && !messagesReady()}>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
</Match>
</Switch>
</div>
@@ -1,4 +1,5 @@
import type { SessionInfo } from "@opencode/client/promise"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
@@ -9,14 +10,12 @@ import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { useSettingsSurface } from "@/settings/surface"
import { pathKey } from "@/workspaces/path-key"
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
@@ -43,9 +42,9 @@ export function SessionProjectMenu(props: {
}) {
const server = useServer()
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const layout = useLayout()
const settingsSurface = useSettingsSurface()
const navigate = useNavigate()
const [state, setState] = createStore({
open: false,
@@ -65,13 +64,11 @@ export function SessionProjectMenu(props: {
}),
)
}
const openProjectSettings = () => {
const openProjectSettings = async () => {
const current = props.project
if (!current) return
settingsSurface.openProject({
server: ServerConnection.key(server.conn),
project: current.worktree,
})
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
}
return (
@@ -194,7 +191,7 @@ export function SessionProjectMenu(props: {
</Menu.Item>
</Tooltip>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
@@ -1,69 +0,0 @@
import {
createComputed,
createMemo,
createRoot,
getOwner,
on,
onCleanup,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import type { TimelineSessionSource } from "./controller"
export function createTimelineCache(
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
visible: Accessor<boolean>,
) {
const owner = getOwner()
let workspace = untrack(session.identity.workspaceKey)
const cache = createScopedCache(
(key) =>
createRoot((dispose) => {
const id = untrack(session.identity.sessionID)
const sessionKey = untrack(session.identity.sessionKey)
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
// A detached view retains its own inputs until it is selected again.
const select = <T>(read: Accessor<T>) =>
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
return {
value: render(
{
identity: {
params: session.identity.params,
sessionID: () => id,
sessionKey: () => sessionKey,
},
data: {
info: select(session.data.info),
parent: select(session.data.parent),
parentID: select(session.data.parentID),
status: select(session.data.status),
},
history: { messages: select(session.history.messages) },
},
active,
),
dispose,
}
}, owner),
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
)
onCleanup(cache.clear)
const syncWorkspace = (key: string) => {
if (workspace === key) return
workspace = key
cache.clear()
}
// Providers follow the selected Location even while its history is loading.
// Dispose detached views before their effects can read the new Location.
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
return () => {
// A tab's render can run before the workspace watcher in the same batch.
// Clear the old workspace here, and let that later watcher keep this view.
syncWorkspace(session.identity.workspaceKey())
return cache.get(session.identity.sessionKey()).value
}
}
@@ -349,7 +349,6 @@ export function SessionSummaryPanel(props: {
type MessageTimelineProps = {
hideHeader?: boolean
active?: boolean
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
@@ -364,7 +363,6 @@ type MessageTimelineProps = {
onSelectionInteraction: (event: MouseEvent) => void
pinned: boolean
centered: boolean
reserveReviewToggle: boolean
setContentRef: (el: HTMLDivElement) => void
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
onReview: () => void
@@ -460,7 +458,6 @@ function MessageTimelineView(
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
active: () => props.active !== false,
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
@@ -554,12 +551,6 @@ function MessageTimelineView(
if (await props.action.rename(title.draft)) setTitle("editing", false)
}
createEffect(() => {
if (props.active !== false) return
setSummary(false)
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
})
const rowRenderer = createSessionTimelineRowRenderer({
sessionID: () => sessionID()!,
status: sessionStatus,
@@ -854,7 +845,7 @@ function MessageTimelineView(
</Popover>
)}
</Show>
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
<SessionHeader />
</div>
)}
</Show>
+4 -5
View File
@@ -11,17 +11,17 @@ export {
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const [resource] = createResource(
() => input.session.identity.sessionID(),
async (id) => {
if (!id) return
const owner = input.session.ownership.capture()
const key = input.session.identity.sessionKey()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
await enrichLeadingTurn({
current: owner.current,
current: () => input.session.identity.sessionKey() === key,
messages: () => data.session.message.list(id),
more: () => data.session.message.more(id),
loading: () => data.session.message.loading(id),
@@ -29,13 +29,12 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
return id
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
return !id || data.session.message.list(id).length > 0 || !resource.loading
})
const more = () => {
const id = input.session.identity.sessionID()
@@ -16,35 +16,6 @@ test("matches only the scroll element or an ancestor containing it", () => {
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("restores a view observed before its first attachment", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const viewport = targetWindow.document.createElement("div")
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 240,
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const connections: boolean[] = []
const cleanup = observeElementOffsetReconnectAware(
instance,
(offset) => {
instance.scrollOffset = offset
},
() => connections.push(viewport.isConnected),
)
try {
mutations.append(targetWindow.document.body, viewport)
await frames(2, targetWindow)
expect(connections).toEqual([true])
expect(instance.scrollOffset).toBe(0)
} finally {
cleanup()
await targetWindow.happyDOM.close()
}
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
@@ -20,8 +20,7 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
cleanupOffset?.()
}
// Cached views can be constructed before their first attachment to the page.
let removed = !element.isConnected
let removed = false
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
@@ -2,7 +2,6 @@ import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
observeElementRect,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
@@ -50,7 +49,6 @@ type Projection = Pick<
>
type Input = {
active?: Accessor<boolean>
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
@@ -85,7 +83,6 @@ type ViewProps = {
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const active = () => input.active?.() !== false
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
@@ -137,7 +134,7 @@ export function createTimelineVirtualizer(input: Input) {
!(
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 64 &&
row.group.refs.length <= 16 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
@@ -159,7 +156,6 @@ export function createTimelineVirtualizer(input: Input) {
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
let batchingColdSizes = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -167,22 +163,13 @@ export function createTimelineVirtualizer(input: Input) {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementRect: (instance, callback) => {
reportRect = callback
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
})
},
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
if (!active()) return
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (!active()) return
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
@@ -194,11 +181,6 @@ export function createTimelineVirtualizer(input: Input) {
// its snapshot on attachment, but later explicit measurements must read layout
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
measureElement: (element, entry, instance) => {
if (!active() || !element.isConnected)
return (
instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element))) ??
fallbackItemSize
)
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
@@ -210,7 +192,6 @@ export function createTimelineVirtualizer(input: Input) {
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -240,7 +221,6 @@ export function createTimelineVirtualizer(input: Input) {
// Read the whole measurement delivery before committing reactive row sizes.
// Otherwise each row can render and force layout before the next is measured.
virtualizer.resizeItem = (index, size) => {
if (!active()) return
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
@@ -256,7 +236,6 @@ export function createTimelineVirtualizer(input: Input) {
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
if (!active()) return
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
batchingColdSizes = coldPending && input.pinned()
@@ -292,21 +271,7 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
// its real viewport before restoring the offset and admitting rows.
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
if (input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealMessage?.((id, partID) => {
if (!active()) return
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
@@ -317,7 +282,6 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => {
if (!active() || !listRoot()?.isConnected) return
input.onPin()
virtualizer.scrollToEnd()
})
@@ -328,7 +292,6 @@ export function createTimelineVirtualizer(input: Input) {
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
if (!active()) return
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
@@ -346,7 +309,7 @@ export function createTimelineVirtualizer(input: Input) {
)
}
const settleColdBottom = () => {
if (!active() || !coldPending || settleQueued) return
if (!coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
@@ -409,7 +372,7 @@ export function createTimelineVirtualizer(input: Input) {
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
if (active()) input.setScrollRef(root)
input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
}
@@ -466,7 +429,6 @@ export function createTimelineVirtualizer(input: Input) {
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (!active()) return
const root = event.currentTarget
const previousTop = scrollTop
const previousMaxScroll = maxScroll
@@ -492,8 +454,15 @@ export function createTimelineVirtualizer(input: Input) {
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
// Prepending history changes data-index, not the keyed element's identity.
// Its observer reads the current index and delivers any actual size change.
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
@@ -515,19 +484,6 @@ export function createTimelineVirtualizer(input: Input) {
<div
ref={(value) => {
element = value
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
return
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
// Measure the mounted tail and pin it in this render's microtask instead.
queueMicrotask(() => {
if (!input.pinned() || !virtualContent?.isConnected) return
virtualizer.elementsCache.forEach((item) => {
if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight)
})
virtualizer.resizeItem(item().index, element.offsetHeight)
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
virtualizer.scrollToEnd()
})
}}
data-index={item().index}
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
@@ -537,7 +493,7 @@ export function createTimelineVirtualizer(input: Input) {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (active() && element.isConnected) virtualizer.measureElement(element)
if (element.isConnected) virtualizer.measureElement(element)
})
})}
</div>
@@ -599,7 +555,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
if (active()) input.setContentRef(element)
input.setContentRef(element)
}}
style={{
height: `${virtualizer.getTotalSize()}px`,
@@ -634,11 +590,9 @@ export function createTimelineVirtualizer(input: Input) {
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
}
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
})
return {
+1 -2
View File
@@ -1,9 +1,8 @@
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettingsSurface } from "./surface"
import type { SettingsRootTab } from "./surface"
export function useSettingsDialog(defaultValue?: SettingsRootTab) {
export function useSettingsDialog(defaultValue?: string) {
const settings = useSettingsSurface()
return () => settings.open(defaultValue)
}
@@ -1,5 +1,4 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ConfigPreferences, ConfigUpdatePreferencesInput } from "@opencode/client/promise"
import type { ColorScheme } from "@opencode/ui/theme/context"
import { useTheme } from "@opencode/ui/theme/context"
import {
@@ -15,100 +14,32 @@ import {
useSettings,
} from "@/settings/model"
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController } from "./behavior"
import { createSoundPreviewController, type ShellOption } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerCtx } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createServerPreferencesController(server: Accessor<ServerConnection.Any>) {
const language = useLanguage()
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
const source = () => ServerConnection.key(server())
const [preferences, preferencesActions] = createResource<ConfigPreferences, ServerConnection.Key>(
source,
() =>
serverCtx()
.sdk.api.config.preferences()
.catch(() => ({})),
{ initialValue: {} },
)
const [shells] = createResource(
source,
() =>
serverCtx()
.sdk.api.config.shells()
.catch(() => []),
{ initialValue: [] },
async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [providers] = createResource(
source,
() =>
serverCtx()
.sdk.api.websearch.providers()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
const update = async (patch: ConfigUpdatePreferencesInput) => {
const context = serverCtx()
const previous = preferences.latest
preferencesActions.mutate({
...previous,
...(patch.shell === undefined ? {} : { shell: patch.shell ?? undefined }),
...(patch.websearch === undefined ? {} : { websearch: patch.websearch ?? undefined }),
})
await context.sdk.api.config
.updatePreferences(patch)
.then(preferencesActions.mutate)
.catch((error: unknown) => {
preferencesActions.mutate(previous)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
})
}
const websearchOptions = createMemo(() => {
const options = providers.latest.map((provider) => ({ value: provider.id, label: provider.name }))
const selected = preferences.latest.websearch
const configured = selected && selected.provider !== "random" ? selected.provider : undefined
return [
{ value: "random" as const, label: language.t("session.websearch.any") },
...options,
...(configured && !options.some((option) => option.value === configured)
? [{ value: configured, label: configured }]
: []),
{ value: false as const, label: language.t("session.websearch.disable") },
]
})
const websearchCurrent = createMemo(() => {
const selection = preferences.latest.websearch
const value = selection === false ? false : (selection?.provider ?? "random")
return websearchOptions().find((option) => option.value === value) ?? websearchOptions()[0]
})
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
return {
shell: {
shells: () => shells.latest,
current: () => preferences.latest.shell ?? "",
select: (value: string) => {
if (value === (preferences.latest.shell ?? "")) return
void update({ shell: value || null })
},
},
websearch: {
options: websearchOptions,
current: websearchCurrent,
select: (value: string | false) => {
void update({ websearch: value === false ? false : { provider: value } })
},
shells: () => shells.latest,
current,
select: (value: string) => {
if (value === current()) return
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// void serverSync.updateConfig({ shell: value })
},
}
}
@@ -208,6 +139,6 @@ export function createSoundSettingsController() {
}
}
export type ShellSettingsController = ReturnType<typeof createServerPreferencesController>["shell"]
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+55 -26
View File
@@ -21,10 +21,12 @@ import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createShellOptions,
createShellSettingsController,
type AppearanceSettingsController,
type ShellSettingsController,
} from "./controllers"
import "@/settings/settings.css"
import { ServerConnection } from "@/runtime/server/registry"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
@@ -95,7 +97,7 @@ const WorkspaceDestinationSetting: Component = () => {
)
}
export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
createShellOptions({
@@ -293,12 +295,15 @@ const LanguageSetting = () => {
)
}
export const SettingsGeneral: Component = () => {
export const SettingsGeneral: Component<{
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -323,32 +328,10 @@ export const SettingsGeneral: Component = () => {
<WorkspaceDestinationSetting />
<AutoApprovePermissionsSetting />
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<Show when={desktop()}>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -387,6 +370,18 @@ export const SettingsGeneral: Component = () => {
<h3 class="settings-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
@@ -398,6 +393,18 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -475,6 +482,26 @@ export const SettingsGeneral: Component = () => {
</div>
)
// We can probably remove this, right?
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</Show>
)
return (
<>
<div class="settings-tab-header">
@@ -487,7 +514,7 @@ export const SettingsGeneral: Component = () => {
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<div class="settings-tab-body">
<GeneralSection />
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
@@ -506,6 +533,8 @@ export const SettingsGeneral: Component = () => {
<UpdatesSection />
</Show>
<DisplaySection />
<AdvancedSection />
</div>
</>
@@ -417,7 +417,7 @@ function SettingsKeybindsView(props: {
</div>
</div>
<div class="settings-tab-body">
<div class="settings-shortcuts settings-section-stack">
<div class="settings-shortcuts flex flex-col gap-8">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
+2 -6
View File
@@ -1,10 +1,6 @@
import type { Component, JSX } from "solid-js"
import "@/settings/settings.css"
export const SettingsList: Component<{ children: JSX.Element; variant?: "catalog" }> = (props) => {
return (
<div data-component="settings-list" data-variant={props.variant}>
{props.children}
</div>
)
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-list">{props.children}</div>
}
+1 -5
View File
@@ -288,12 +288,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
const mono = monoFontFamily(store.appearance?.mono)
root.style.setProperty("--font-family-mono", mono)
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
// Inline code can first appear during history backfill. Load its selected
// face with the shell so that font discovery does not resize that mount.
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
})
return {
+4 -2
View File
@@ -12,6 +12,7 @@ import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
import { popularProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { InlineServerSelect } from "@/settings/server-select"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
@@ -64,6 +65,7 @@ export const SettingsModels: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<InlineServerSelect />
</div>
<div class="settings-tab-search">
<TextInput
@@ -158,12 +160,12 @@ export const SettingsModels: Component = () => {
height={PROVIDER_ICON_SIZE}
class="settings-models-provider-icon shrink-0"
/>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsList variant="catalog">
<SettingsList>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
-122
View File
@@ -1,122 +0,0 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
export type SettingsNavItem = {
value: string
label: string
icon: ComponentProps<typeof Icon>["name"]
disabled?: boolean
onPrefetch?: () => void
}
export type SettingsNavGroup = {
label?: string
action?: JSX.Element
items: readonly SettingsNavItem[]
}
export function SettingsNavigation(props: {
value: string
groups: readonly SettingsNavGroup[]
backLabel: string
onBack: () => void
onChange: (value: string) => void
mobileAction?: JSX.Element
children: JSX.Element
}) {
const current = () => props.groups.flatMap((group) => group.items).find((item) => item.value === props.value)
return (
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={props.onChange} class="settings">
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
</button>
<div class="settings-mobile-actions">
{props.mobileAction}
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>{current()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={props.value} onChange={props.onChange}>
<For each={props.groups}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group.items}>
{(item) => (
<Menu.RadioItem
value={item.value}
disabled={item.disabled}
closeOnSelect
onPointerEnter={(event: PointerEvent) => {
if (item.disabled || event.pointerType === "touch") return
item.onPrefetch?.()
}}
onFocus={() => !item.disabled && item.onPrefetch?.()}
>
<Icon name={item.icon} />
{item.label}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
</button>
<div class="settings-nav-groups">
<For each={props.groups}>
{(group) => (
<div class="settings-nav-group">
<Show when={group.label || group.action}>
<div class="settings-nav-group-header" data-component="settings-nav-group-header">
<span>{group.label}</span>
{group.action}
</div>
</Show>
<For each={group.items}>
{(item) => (
<Tabs.Trigger
value={item.value}
disabled={item.disabled}
onPointerEnter={(event: PointerEvent) => {
if (item.disabled || event.pointerType === "touch") return
item.onPrefetch?.()
}}
onFocus={() => !item.disabled && item.onPrefetch?.()}
>
<Icon name={item.icon} />
{item.label}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
</Tabs.List>
{props.children}
</Tabs>
)
}
@@ -71,7 +71,7 @@ export const SettingsNotifications: Component = () => {
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<div class="settings-tab-body">
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
@@ -8,7 +8,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useMcpToggle } from "@/providers/connect/mcp"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { InlineServerSelect } from "@/settings/server-select"
import "@/settings/settings.css"
interface McpRowItem {
@@ -63,11 +63,12 @@ export const SettingsExtensions: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs settings-subtabs">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
@@ -77,20 +78,18 @@ export const SettingsExtensions: Component = () => {
<Tabs.Content value="mcps">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="settings-extension-heading text-13-medium">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-text-muted">
{language.t("settings.extensions.manageConfig")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<SettingsList variant="catalog">
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={mcps()}>
{(item) => (
<div class="settings-extension-row">
<div class="settings-extension-lead">
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="settings-extension-name truncate">{item.name}</span>
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
@@ -98,57 +97,58 @@ export const SettingsExtensions: Component = () => {
</div>
)}
</For>
</SettingsList>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="settings-extension-heading text-13-medium">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-text-muted">
{language.t("settings.extensions.manageConfig")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<SettingsList variant="catalog">
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={plugins()}>
{(plugin) => (
<div class="settings-extension-row">
<div class="settings-extension-lead">
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
<span class="settings-extension-name truncate">{plugin.name}</span>
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
</div>
</div>
)}
</For>
</SettingsList>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="skills">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="settings-extension-heading text-13-medium">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink class="settings-extension-link text-13-regular" href="https://opencode.ai/docs/skills/">
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<SettingsList variant="catalog">
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={skills()}>
{(skill) => (
<div class="settings-extension-row">
<div class="settings-extension-lead">
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
<span class="settings-extension-name truncate">{skill.name}</span>
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</SettingsList>
</div>
</div>
</Tabs.Content>
</Tabs>
@@ -9,6 +9,8 @@ import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { SettingsServerScope } from "@/settings/server-scope"
import { InlineServerSelect } from "@/settings/server-select"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
@@ -42,7 +44,11 @@ export const SettingsProviders: Component<{
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
}
const connected = createMemo(() => {
@@ -128,13 +134,14 @@ export const SettingsProviders: Component<{
<h2 class="settings-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned settings-providers">
<div class="settings-tab-body settings-providers">
<div class="settings-section" data-component="connected-providers-section">
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList variant="catalog">
<SettingsList>
<Show
when={connected().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
@@ -175,7 +182,7 @@ export const SettingsProviders: Component<{
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.providers.section.popular")}</h3>
<SettingsList variant="catalog">
<SettingsList>
<For each={popular()}>
{(item) => (
<div class="settings-provider-row">
+15 -1
View File
@@ -1,8 +1,22 @@
import { type ParentProps } from "solid-js"
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { ModelsProvider } from "@/providers/models/models"
import { ServerProvider } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerProvider conn={props.server}>
@@ -0,0 +1,48 @@
import { Show, createMemo, type Component } from "solid-js"
import { Select } from "@opencode/ui/select"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, serverName } from "@/runtime/server/registry"
const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers
export const InlineServerSelect: Component<{
all?: {
label: string
selected: () => boolean
onSelect: () => void
}
onServerSelect?: () => void
}> = (props) => {
const global = useGlobal()
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
return (
<Show when={options().length > 1}>
<Select
data-action="settings-server-select"
options={options()}
current={current()}
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
label={(server) =>
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
}
optionDisabled={(server) =>
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
}
placement="bottom-end"
gutter={6}
onSelect={(server) => {
if (!server) return
if (server.type === "all") {
props.all?.onSelect()
return
}
global.settings.server.set(ServerConnection.key(server))
props.onServerSelect?.()
}}
/>
</Show>
)
}
@@ -1,43 +0,0 @@
import { createMemo } from "solid-js"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useWslServers } from "@/servers/wsl/context"
import type { WslServerItem } from "@/servers/wsl/types"
export type SettingsServer = {
key: ServerConnection.Key
name: string
connection?: ServerConnection.Any
wsl?: WslServerItem
}
export function settingsServers(connections: readonly ServerConnection.Any[], wsl: readonly WslServerItem[]) {
const configured = new Map(wsl.map((item) => [item.config.id, item]))
const connected = new Set(connections.map(ServerConnection.key))
return [
...connections.map((connection): SettingsServer => {
const key = ServerConnection.key(connection)
const item = configured.get(key)
return {
key,
name: item?.config.distro ?? (serverName(connection) || key),
connection: item && item.runtime.kind !== "ready" ? undefined : connection,
wsl: item,
}
}),
...wsl
.filter((item) => !connected.has(ServerConnection.Key.make(item.config.id)))
.map(
(item): SettingsServer => ({
key: ServerConnection.Key.make(item.config.id),
name: item.config.distro,
wsl: item,
}),
),
]
}
export function useSettingsServers() {
const servers = useServers()
const wsl = useWslServers()
return createMemo(() => settingsServers(servers.list, wsl.data?.servers ?? []))
}
+108 -108
View File
@@ -1,139 +1,139 @@
import { Badge } from "@opencode/ui/badge"
import { Select } from "@opencode/ui/select"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { useDialog } from "@opencode/ui/context/dialog"
import { createMemo, Show, type Component } from "solid-js"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/servers/registry/row-menu"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { AddServerMenu, WslServerSettings } from "@/servers/wsl/settings"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { ShellSetting } from "@/settings/general/general"
import { createServerPreferencesController } from "@/settings/general/controllers"
import type { SettingsServer } from "./inventory"
import { SshServerSettings } from "@/servers/ssh/settings"
import { useSsh } from "@/servers/ssh/context"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
import "@/settings/settings.css"
const WebSearchSetting: Component<{
controller: ReturnType<typeof createServerPreferencesController>["websearch"]
}> = (props) => {
const language = useLanguage()
return (
<SettingsRow
title={language.t("settings.server.preferences.websearch.title")}
description={language.t("settings.server.preferences.websearch.description")}
>
<Select
options={props.controller.options()}
current={props.controller.current()}
value={(option) => String(option.value)}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRow>
)
}
export const SettingsServerGeneral: Component<{
entry: SettingsServer
nested?: boolean
onAddServer?: () => void
onServerChange?: (server: ServerConnection.Any) => void
}> = (props) => {
export const SettingsServers: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerCollectionController()
const health = createMemo(() => controller.collection.health()[props.entry.key])
const edit = (server: ServerConnection.Http) =>
void dialog.push(() => <DialogServer mode="edit" server={server} onSave={props.onServerChange} />)
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const ssh = useSsh()
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.collection.items().filter((item) => !isWslServer(item) && item.type !== "ssh")
const query = store.filter.trim()
if (!query) return items
return fuzzysort
.go(query, items, {
keys: [(item) => serverName(item), (item) => item.http.url],
})
.map((result) => result.obj)
})
const openAdd = () => {
void dialog.push(() => <DialogServer mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
void dialog.push(() => <DialogServer mode="edit" server={server} />)
}
return (
<>
<div class="settings-tab-header">
<div
class="settings-tab-header settings-servers-header"
classList={{ "settings-tab-header--stacked": showSearch() }}
>
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">
{props.nested ? props.entry.name : language.t("settings.section.server")}
</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t(props.nested ? "settings.server.description" : "settings.servers.description")}
</span>
<h2 class="settings-tab-title">{language.t("status.popover.tab.servers")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
</div>
<Show when={!props.nested && props.onAddServer}>
<AddServerMenu onAddServer={() => props.onAddServer?.()} />
</Show>
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
<div class="settings-tab-search">
<TextInput
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("dialog.server.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.server.search.placeholder")}
/>
<Show when={store.filter}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</Show>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section settings-server-connection" data-component="settings-server-connection">
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
<div class="settings-tab-body settings-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0 || ssh.servers.some((item) => item.saved)}
fallback={
<div class="settings-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
<Show when={store.filter}>
<span class="settings-servers-status-filter">&quot;{store.filter}&quot;</span>
</Show>
</div>
}
>
<SettingsList>
<Show
when={props.entry.wsl}
fallback={
<Show when={props.entry.connection}>
{(server) => {
const ssh = () => {
const connection = server()
if (connection.type === "ssh") return connection
}
return (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator
health={health()}
connecting={ssh()?.connecting}
authenticationRequired={ssh()?.authenticationRequired}
/>
<div class="settings-servers-copy">
<bdi class="settings-servers-name" dir="auto">
{serverName(server()) || props.entry.key}
</bdi>
<bdi class="settings-servers-meta" dir="ltr">
{ssh()?.host ?? server().http.url}
</bdi>
</div>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && controller.defaults.key() === props.entry.key}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
<SshServerSettings filter={store.filter} domain={controller} />
<WslServerSettings domain={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.collection.health()[key]
const isDefault = () => controller.defaults.key() === key
return (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-servers-copy">
<span class="settings-servers-name">{serverName(item)}</span>
<Show when={health()?.version}>
<span class="settings-servers-meta">v{health()?.version}</span>
</Show>
</div>
)
}}
</Show>
}
>
{(item) => <WslServerSettings domain={controller} servers={() => [item()]} />}
</Show>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && isDefault()}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
</div>
</div>
)
}}
</For>
</SettingsList>
</section>
<Show when={props.entry.connection} keyed>
{(server) => <ServerPreferences server={server} />}
</Show>
</div>
</>
)
}
function ServerPreferences(props: { server: ServerConnection.Any }) {
const language = useLanguage()
const preferences = createServerPreferencesController(() => props.server)
return (
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
<SettingsList>
<ShellSetting controller={preferences.shell} />
<WebSearchSetting controller={preferences.websearch} />
</SettingsList>
</section>
)
}
+125 -170
View File
@@ -51,12 +51,11 @@
flex: 1;
min-width: 0;
max-width: 720px;
/* Leave room for raised card shadows inside the scrollport. */
padding-inline: 4px;
}
.settings-screen .settings-tab-header {
padding: 48px 0 0;
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-screen .settings-tab-body {
@@ -71,56 +70,6 @@
gap: 16px;
}
.settings-nav-groups {
display: flex;
width: 100%;
flex-direction: column;
gap: 16px;
}
.settings-nav-group {
display: flex;
width: 100%;
flex-direction: column;
gap: 4px;
}
.settings-nav-group-header {
display: flex;
height: 28px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-inline: 6px;
border-radius: 4px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
}
.settings-nav-group-header [data-component="icon-button-v2"] {
opacity: 0;
}
.settings-nav-group-header:focus-within [data-component="icon-button-v2"] {
opacity: 1;
}
.settings-nav-group-header:focus-within {
background: var(--v2-background-bg-layer-03);
}
@media (hover: hover) {
.settings-nav-group-header:hover {
background: var(--v2-background-bg-layer-03);
}
.settings-nav-group-header:hover [data-component="icon-button-v2"] {
opacity: 1;
}
}
.settings-mobile-nav {
display: none;
}
@@ -129,13 +78,6 @@
min-width: 0;
}
.settings-mobile-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.settings-mobile-menu-trigger > span {
overflow: hidden;
text-overflow: ellipsis;
@@ -307,18 +249,8 @@
position: sticky;
top: 0;
z-index: 10;
padding: 40px 40px 0;
background: var(--v2-background-bg-base);
}
.settings-tab-header::before {
content: "";
position: absolute;
inset-inline: 0;
top: 100%;
height: 24px;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
pointer-events: none;
padding: 40px 40px 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-tab-header-row {
@@ -339,13 +271,9 @@
.settings-tab-body {
display: flex;
flex-direction: column;
gap: 32px;
gap: 36px;
width: 100%;
padding: 24px 40px calc(80px + var(--settings-bottom-inset, 0px));
}
.settings-tab-body--sectioned {
padding-top: 32px;
padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px));
}
[data-slot="settings-row-description"] a.settings-link {
@@ -365,18 +293,16 @@
}
.settings-section-title {
padding: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
padding-bottom: 8px;
font-size: 15px;
font-weight: 640;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-section-stack {
display: flex;
width: 100%;
flex-direction: column;
gap: 32px;
.settings-section-title + [data-component="settings-list"] {
margin-top: -4px;
margin-bottom: 0;
}
[data-component="settings-list"] {
@@ -386,12 +312,6 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
[data-component="settings-list"][data-variant="catalog"] {
--settings-list-row-padding: 16px;
--settings-list-icon-gap: 8px;
padding-inline: 16px;
}
.settings-interface-feature [data-component="settings-list"] {
background-color: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
@@ -402,7 +322,7 @@
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding-block: var(--settings-list-row-padding, 20px);
padding-block: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
@@ -455,12 +375,6 @@
padding: 4px;
}
[data-component="settings-list"][data-variant="catalog"]
[data-slot="settings-row-control"]
> :is(div:has([data-component="switch"]), [data-component="switch"]) {
padding: 0;
}
@media (min-width: 640px) {
[data-slot="settings-row-control"] {
width: auto;
@@ -551,7 +465,8 @@
padding-top: 12px;
}
.settings-screen .settings-tab-title {
.settings-screen .settings-tab-title,
.settings-screen .settings-section-title {
line-height: var(--line-height-base);
}
@@ -590,7 +505,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding-block: var(--settings-list-row-padding, 20px);
padding-block: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
@@ -619,7 +534,7 @@
min-width: 0;
flex: 1;
align-items: flex-start;
gap: var(--settings-list-icon-gap, 10px);
gap: 10px;
}
.settings-provider-lead:not(:has(.settings-provider-copy)) {
@@ -645,12 +560,12 @@
.settings-provider-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-provider-description {
margin: 0;
margin-block: -3.5px;
font-size: 13px;
font-weight: 440;
line-height: 20px;
@@ -658,7 +573,7 @@
}
.settings-provider-empty {
padding-block: var(--settings-list-row-padding, 20px);
padding-block: 20px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
@@ -697,10 +612,30 @@
color: var(--v2-text-text-accent-hover);
}
.settings-tab-body.settings-providers {
gap: 32px;
}
.settings-tab-header:has(+ .settings-tab-body.settings-providers) {
padding-bottom: 32px;
}
.settings-providers .settings-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-providers .settings-section-title + [data-component="settings-list"] {
margin-top: 16px;
}
.settings-tab-header.settings-tab-header--stacked {
display: flex;
flex-direction: column;
gap: 24px;
gap: 32px;
padding-bottom: 32px;
}
.settings-tab-header--stacked > .settings-tab-header-row {
@@ -793,16 +728,25 @@
gap: 8px;
}
.settings-models-group-title {
.settings-models .settings-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
line-height: 16px;
}
.settings-models [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
.settings-models [data-component="settings-list"] {
padding-inline: 16px;
}
.settings-models .settings-section-title + [data-component="settings-list"] {
margin-top: 0;
}
.settings-models [data-slot="settings-row-description"]:empty {
display: none;
}
@@ -848,6 +792,17 @@
color: var(--v2-text-text-base);
}
.settings-shortcuts .settings-section {
gap: 16px;
}
.settings-shortcuts .settings-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-shortcuts [data-component="settings-list"] {
display: flex;
flex-direction: column;
@@ -936,23 +891,39 @@
color: var(--v2-text-text-base);
}
.settings-servers-row {
.settings-tab-body.settings-servers {
gap: 0;
}
.settings-tab-header.settings-servers-header {
padding-bottom: 24px;
}
.settings-servers-header .settings-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-server-connection [data-component="settings-list"] {
padding-inline: 16px;
.settings-tab-header.settings-servers-header.settings-tab-header--stacked {
gap: 24px;
padding-bottom: 24px;
}
.settings-server-connection .settings-servers-row {
padding-block: 20px;
.settings-servers [data-component="settings-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
.settings-servers-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-servers-row:not(:last-child) {
@@ -999,8 +970,34 @@
color: var(--v2-text-text-muted);
}
.settings-servers-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-servers-status-filter {
color: var(--v2-text-text-base);
}
.settings-tab-header.settings-workspaces-header {
padding-bottom: 24px;
}
.settings-workspaces-header .settings-tab-title {
font-weight: 610;
}
.settings-tab-body.settings-workspaces {
gap: 0;
gap: 16px;
}
.settings-workspaces-toolbar {
@@ -1009,7 +1006,13 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 8px;
}
.settings-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-workspaces-toolbar-actions {
@@ -1252,7 +1255,7 @@
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 0;
padding: 24px 20px 20px;
}
.settings-tab-body.settings-workspaces {
@@ -1368,68 +1371,20 @@
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: min(280px, 100%);
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
padding-inline: 0 !important;
}
.settings-subtabs > [data-slot="tabs-v2-content"] {
padding-top: 24px;
}
.settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-block: var(--settings-list-row-padding, 16px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-extension-row:last-child {
border-bottom: 0;
}
.settings-extension-lead {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: var(--settings-list-icon-gap, 8px);
}
.settings-extension-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-extension-heading,
.settings-extension-name {
color: var(--text-strong);
}
.settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: underline;
}
.settings-extension-link:hover {
color: var(--v2-text-text-accent-hover) !important;
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
+203 -401
View File
@@ -1,18 +1,10 @@
import { Tabs } from "@opencode/ui/tabs"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { useServerCollectionController } from "@/servers/registry/controller"
import { AddServerMenu } from "@/servers/wsl/settings"
import { DialogServer } from "@/servers/connect/dialog"
import { LocationProvider } from "@/workspaces/location"
import { SettingsGeneral } from "./general/general"
import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
@@ -20,102 +12,94 @@ import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
import { useSettingsServers, type SettingsServer } from "./servers/inventory"
import { SettingsServers } from "./servers/servers"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { useWorkspacesPrefetch } from "./workspaces/queries"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsAbout } from "./about/about"
import { SettingsServerDataScope } from "./server-scope"
import { SettingsNavigation, type SettingsNavGroup } from "./navigation"
import { SettingsProjectGeneral } from "./workspaces/project"
import { ProjectSettingsExtensions } from "./workspaces/project-extensions"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
const rootClientTabs = [
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
const sections = [
[
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
],
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
],
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
[{ value: "about", icon: "info", label: "settings.tab.about" }],
] as const
const serverTabs = [
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
] as const
const trailingTabs = [
{ value: "experimental", icon: "flask", label: "settings.tab.experimental" },
{ value: "about", icon: "info", label: "settings.tab.about" },
] as const
const nestedServerTabs = [
{ value: "general", icon: "server", label: "settings.general.section.general" },
...serverTabs,
] as const
const nestedProjectTabs = [
{ value: "general", icon: "folder", label: "settings.general.section.general" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
] as const
export function SettingsScreen() {
const surface = useSettingsSurface()
export const SettingsScreen: Component = () => {
const language = useLanguage()
const dialog = useDialog()
const servers = useSettingsServers()
const surface = useSettingsSurface()
const layout = useLayout()
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
let viewType = surface.view().type
onMount(() => root?.focus({ preventScroll: true }))
createEffect(() => {
const next = surface.view().type
if (next === viewType) return
viewType = next
queueMicrotask(() => root?.focus({ preventScroll: true }))
onMount(() => {
root?.focus({ preventScroll: true })
})
const connection = (key: string) => servers().find((item) => item.key === key)
const project = (server: ServerConnection.Any, directory: string) => {
const context = global.ensureServerCtx(server)
const value =
context.projects.list().find((item) => item.worktree === directory) ??
context.sync.data.project.find((item) => item.worktree === directory)
return value ? { expanded: false, ...value } : undefined
}
const targetServer = createMemo(() => {
const view = surface.view()
if (view.type === "root") return undefined
return connection(view.server)
})
const targetProject = createMemo(() => {
const view = surface.view()
const server = targetServer()
if (view.type !== "project" || !server) return undefined
return server.connection && project(server.connection, view.project)
})
createEffect(() => {
const view = surface.view()
if (view.type === "root") return
const target = targetServer()
if (!target) {
surface.back()
return
const server = createMemo(() => {
const route = surface.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
}
case "session":
return servers.list.find((item) => ServerConnection.key(item) === route.server)
case "home":
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
}
if (view.type === "project" && !target.connection) surface.replaceServer(target.key)
})
const serverCtx = useServerCtx(server)
createEffect(() => {
const view = surface.view()
if (view.type !== "server" || servers().length !== 1) return
surface.open(view.tab === "general" ? "servers" : view.tab)
const current = server()
if (current) global.settings.server.set(ServerConnection.key(current))
})
const directory = createMemo(() => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = surface.route()
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
return undefined
})
const showProviders = () => {
dialog.close()
surface.open("providers")
}
return (
<div
ref={root}
@@ -125,320 +109,138 @@ export function SettingsScreen() {
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
event.preventDefault()
surface.back()
surface.close()
}}
>
<Switch>
<Match when={surface.view().type === "root"}>
<RootSettings />
</Match>
<Match when={surface.view().type === "server"}>
<Show when={targetServer()}>{(server) => <ServerSettings entry={server()} />}</Show>
</Match>
<Match when={surface.view().type === "project"}>
<Show when={targetServer()?.connection} keyed>
{(server) => (
<Show when={targetProject()}>{(project) => <ProjectSettings server={server} project={project()} />}</Show>
)}
</Show>
</Match>
</Switch>
<Tabs
orientation="vertical"
variant="settings"
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
class="settings"
>
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={sections}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
<For each={sections}>
{(group) => (
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-panel">
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="servers" class="settings-panel">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects />
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.tab() === "about"} />
</Tabs.Content>
</Tabs>
</div>
)
}
function RootSettings() {
const language = useLanguage()
const dialog = useDialog()
const surface = useSettingsSurface()
const layout = useLayout()
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
const single = createMemo(() => singleEntry()?.connection)
const prefetchWorkspaces = useWorkspacesPrefetch(single)
const multiple = createMemo(() => inventory().length > 1)
const ordered = createMemo(() => {
const order = new Map(list().map((server, index) => [ServerConnection.key(server), index]))
return inventory().toSorted((a, b) => {
const preferred = Number(b.key === servers.defaults.key()) - Number(a.key === servers.defaults.key())
if (preferred) return preferred
return (order.get(a.key) ?? list().length) - (order.get(b.key) ?? list().length)
})
})
const sourceServer = createMemo(() => {
const route = surface.route()
if (route.type === "session") return connectionFor(list(), route.server)
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return connectionFor(list(), draft?.server)
}
return connectionFor(list(), layout.home.selection().server)
})
const sourceDirectory = useSettingsDirectory(sourceServer)
const addServer = () =>
void dialog.push(() => (
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
...(multiple()
? [
{
label: language.t("status.popover.tab.servers"),
action: <AddServerMenu compact onAddServer={addServer} />,
items: ordered().map((server) => ({
value: `server:${server.key}`,
icon: "server" as const,
label: server.name,
})),
},
]
: [
{
items: [
...serverTabs.map((item) => ({
...item,
label: language.t(item.label),
disabled: !single(),
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
})),
{ value: "servers", icon: "server" as const, label: language.t("settings.section.server") },
],
},
]),
{ items: trailingTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
])
createEffect(() => {
const view = surface.view()
if (view.type !== "root" || !multiple()) return
if (["projects", "workspaces", "providers", "models", "extensions", "servers"].includes(view.tab))
surface.open("general")
})
const change = (value: string) => {
if (value.startsWith("server:")) {
surface.openServer(value.slice("server:".length))
return
}
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
surface.select(value)
}
return (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToApp")}
onBack={() => surface.close()}
onChange={change}
mobileAction={multiple() ? <AddServerMenu compact onAddServer={addServer} /> : undefined}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-panel">
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.view().tab === "about"} />
</Tabs.Content>
<Show when={single()} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={sourceServer() === server ? sourceDirectory() : undefined}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
<Show when={singleEntry()}>
{(entry) => (
<Tabs.Content value="servers" class="settings-panel">
<SettingsServerGeneral entry={entry()} onAddServer={addServer} />
</Tabs.Content>
)}
</Show>
</SettingsNavigation>
)
}
function ServerSettings(props: { entry: SettingsServer }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection)
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: nestedServerTabs.map((item) => ({
...item,
label: item.value === "general" ? props.entry.name : language.t(item.label),
disabled: item.value !== "general" && !props.entry.connection,
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
})),
},
])
createEffect(() => {
if (!props.entry.connection && surface.view().tab !== "general") surface.select("general")
})
const change = (value: string) => {
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
surface.select(value)
}
return (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToSettings")}
onBack={() => surface.back()}
onChange={change}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsServerGeneral
entry={props.entry}
nested
onServerChange={(server) => surface.replaceServer(ServerConnection.key(server))}
/>
</Tabs.Content>
<Show when={props.entry.connection} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={activeDirectory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
</SettingsNavigation>
)
}
function ProjectSettings(props: { server: ServerConnection.Any; project: LocalProject }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.server)
const prefetchWorkspaces = useWorkspacesPrefetch(
() => props.server,
() => props.project.id,
)
const groups: SettingsNavGroup[] = [
{
items: nestedProjectTabs.map((item) => ({
...item,
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
get label() {
return item.value === "general" ? displayName(props.project) : language.t(item.label)
},
})),
},
]
return (
<SettingsServerDataScope server={props.server} directory={props.project.worktree}>
<LocationProvider directory={props.project.worktree}>
<SettingsNavigation
value={surface.view().tab}
groups={groups}
backLabel={language.t("settings.backToProjects")}
onBack={() => surface.back()}
onChange={(value) => surface.select(value)}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsProjectGeneral
server={props.server}
project={props.project}
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces projectID={props.project.id} activeDirectory={activeDirectory()} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</SettingsNavigation>
</LocationProvider>
</SettingsServerDataScope>
)
}
function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) {
return list.find((item) => ServerConnection.key(item) === key)
}
function useSettingsDirectory(server: Accessor<ServerConnection.Any | undefined>) {
const surface = useSettingsSurface()
const tabs = useTabs()
const serverCtx = useServerCtx(server)
return createMemo(() => {
const current = server()
if (!current) return undefined
const key = ServerConnection.key(current)
const route = surface.route()
if (route.type === "session" && route.server === key)
return serverCtx()?.data.session.get(route.sessionId)?.location.directory
if (route.type !== "draft") return undefined
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" && draft.server === key ? draft.directory : undefined
})
}
+10 -124
View File
@@ -3,75 +3,6 @@ import { createEffect, on } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
import { useSettingsServers } from "./servers/inventory"
export type SettingsRootTab =
| "general"
| "appearance"
| "notifications"
| "shortcuts"
| "projects"
| "workspaces"
| "providers"
| "models"
| "extensions"
| "servers"
| "experimental"
| "about"
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
export type SettingsView =
| { type: "root"; tab: SettingsRootTab }
| { type: "server"; server: string; tab: SettingsServerTab }
| {
type: "project"
server: string
project: string
tab: SettingsProjectTab
parent: "root" | "server"
}
const rootTabs: Record<SettingsRootTab, true> = {
general: true,
appearance: true,
notifications: true,
shortcuts: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
servers: true,
experimental: true,
about: true,
}
const serverTabs: Record<SettingsServerTab, true> = {
general: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
}
const projectTabs: Record<SettingsProjectTab, true> = {
general: true,
workspaces: true,
extensions: true,
}
function isRootTab(value: string): value is SettingsRootTab {
return value in rootTabs
}
function isServerTab(value: string): value is SettingsServerTab {
return value in serverTabs
}
function isProjectTab(value: string): value is SettingsProjectTab {
return value in projectTabs
}
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
@@ -80,24 +11,13 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const navigate = useNavigate()
const layout = useLayout()
const command = useCommand()
const servers = useSettingsServers()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view: SettingsView }
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
}>()
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
const view = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" }
let focus: HTMLElement | undefined
const show = (view: SettingsView, replace: boolean) => {
const route = layout.route()
if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement
navigate("/settings", {
replace,
state: { settings: { route: route.type === "settings" ? source() : route, view } },
})
}
createEffect(
on(
open,
@@ -113,50 +33,16 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
return {
active: open,
route: source,
view,
open(tab: SettingsRootTab = "general") {
show({ type: "root", tab }, open())
},
openServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab }, false)
},
replaceServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab }, true)
},
openProject(input: { server: string; project: string; tab?: SettingsProjectTab }) {
show(
{
type: "project",
...input,
parent: servers().length > 1 ? "server" : "root",
tab: input.tab ?? "general",
},
false,
)
},
select(tab: string) {
const current = view()
const next: SettingsView =
current.type === "root" && isRootTab(tab)
? { ...current, tab }
: current.type === "server" && isServerTab(tab)
? { ...current, tab }
: current.type === "project" && isProjectTab(tab)
? { ...current, tab }
: current
show(next, true)
},
back() {
const current = view()
if (current.type === "root") {
command.trigger("common.goBack")
return
tab: () => location.state?.settings?.tab ?? "general",
open(tab = "general") {
const route = layout.route()
if (route.type !== "settings") {
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
}
const parent: SettingsView =
current.type === "server" || current.parent === "root"
? { type: "root", tab: current.type === "server" ? "general" : "projects" }
: { type: "server", server: current.server, tab: "projects" }
show(parent, true)
navigate("/settings", {
replace: open(),
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
})
},
close() {
if (open()) command.trigger("common.goBack")
@@ -0,0 +1,238 @@
.project-settings-dialog [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
.project-settings-dialog [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
.project-settings-v2 {
height: 100%;
}
.project-settings-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.project-settings-panel {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow: hidden !important;
user-select: none;
}
.project-settings-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.project-settings-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.project-settings-scroll {
display: flex;
flex: 1;
flex-direction: column;
gap: 24px;
min-height: 0;
padding: 40px;
overflow-y: auto;
}
.project-settings-page-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.project-settings-page-header h2 {
color: var(--v2-text-text-base);
font-size: 15px;
font-weight: 640;
line-height: 1;
}
.project-settings-page-header > span {
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 440;
line-height: 1;
}
.project-settings-extensions {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 40px;
}
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
margin-top: 24px;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
padding-inline: 0 !important;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 20px;
}
.project-settings-extension-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
}
.project-settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: none;
}
.project-settings-extension-link:hover {
text-decoration: underline;
}
.project-settings-extension-card {
padding-inline: 12px;
overflow: hidden;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: var(--v2-background-bg-base);
}
.project-settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.project-settings-extension-row:last-child {
border-bottom: 0;
}
.project-settings-extension-row-main {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-settings-shared-trigger {
display: flex;
align-items: center;
align-self: flex-start;
gap: 6px;
color: var(--v2-text-text-base);
font-size: 11px;
font-weight: 530;
}
.project-settings-shared-chevron {
width: 12px;
height: 12px;
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
}
.project-settings-shared-chevron.open {
transform: rotate(90deg);
}
.project-settings-shared-count {
padding: 1px 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 9px;
}
@@ -0,0 +1,219 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogFooter } from "@opencode/ui/dialog"
import { Field } from "@opencode/ui/field"
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
import { Tabs } from "@opencode/ui/tabs"
import { Textarea } from "@opencode/ui/textarea"
import { TextInput } from "@opencode/ui/text-input"
import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { LocationProvider } from "@/workspaces/location"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { createEditProjectModel } from "./project-model"
import { ProjectSettingsExtensions } from "./project-extensions"
import { SettingsServerDataScope } from "@/settings/server-scope"
import "@/settings/settings.css"
import "./project-dialog.css"
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<LocationProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</LocationProvider>
</SettingsServerDataScope>
)
}
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
const projectName = () => displayName(props.project)
const [tab, setTab] = createSignal("general")
const Footer = () => (
<DialogFooter>
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</DialogFooter>
)
return (
<Dialog size="x-large" variant="settings" class="project-settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2"
>
<Tabs.List>
<div class="project-settings-nav">
<Tabs.Trigger value="general">
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
<span class="truncate">{projectName()}</span>
</Tabs.Trigger>
<Tabs.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
</Tabs.List>
<Tabs.Content value="general" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<div class="project-settings-page-header">
<h2>{language.t("dialog.project.edit.title")}</h2>
<span>{language.t("project.settings.general.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInput
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<Footer />
</form>
</Tabs.Content>
<Tabs.Content value="scripts" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<div class="project-settings-page-header">
<h2>{language.t("project.settings.scripts")}</h2>
<span>{language.t("project.settings.scripts.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</div>
<Footer />
</form>
</Tabs.Content>
<Tabs.Content value="extensions" class="project-settings-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</Tabs>
</Dialog>
)
}
@@ -1,19 +1,7 @@
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
import { Button } from "@opencode/ui/button"
import { useQuery } from "@tanstack/solid-query"
import {
type Component,
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
type JSX,
} from "solid-js"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -21,9 +9,6 @@ import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { configuredLanguageServers } from "./project-lsp"
import { SettingsList } from "@/settings/list"
import "./project.css"
type SkillItem = {
name: string
@@ -33,54 +18,23 @@ type SkillItem = {
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: JSX.Element }> = (props) => (
<SettingsList variant="catalog">{props.children}</SettingsList>
<div class="project-settings-extension-card">{props.children}</div>
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" | "code"
icon: "mcp" | "cube" | "post-skill"
name: string
description?: JSX.Element
children?: JSX.Element
}> = (props) => (
<div class="settings-extension-row project-settings-extension-row">
<div class="settings-extension-lead">
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<div class="project-settings-extension-row-copy">
<span class="project-settings-extension-row-name settings-extension-name">{props.name}</span>
<Show when={props.description}>
<span class="project-settings-extension-row-description">{props.description}</span>
</Show>
</div>
<span class="project-settings-extension-row-name">{props.name}</span>
</div>
{props.children}
</div>
)
const ProjectSectionHeader: Component<{
kind: "mcps" | "plugins" | "skills"
empty: boolean
action: JSX.Element
}> = (props) => {
const language = useLanguage()
return (
<div class="project-settings-extension-section-header">
<div class="project-settings-extension-section-copy">
<span>
{props.empty
? language.t(`project.settings.extensions.empty.${props.kind}.title`)
: language.t("project.settings.extensions.added")}
</span>
<Show when={props.empty}>
<span class="project-settings-extension-empty-description">
{language.t(`project.settings.extensions.empty.${props.kind}.description`)}
</span>
</Show>
</div>
{props.action}
</div>
)
}
const SharedSection: Component<{
count: number
children: JSX.Element
@@ -92,21 +46,13 @@ const SharedSection: Component<{
<div class="project-settings-shared">
<button
type="button"
class="settings-models-group-trigger project-settings-shared-trigger"
class="project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<span class="settings-models-group-chevron">
<Icon
name="fill-triangle-down"
size="small"
classList={{ "project-settings-shared-chevron": true, open: open() }}
/>
</span>
<span class="project-settings-shared-label">
<span class="settings-models-group-title">{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</span>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
@@ -116,95 +62,6 @@ const SharedSection: Component<{
)
}
const ProjectLanguageServers: Component = () => {
const language = useLanguage()
const server = useServerSDK()
const location = useWorkspaceLocation()
const config = useQuery(() => ({
queryKey: [server.scope, "settings-project-language-servers", location().directory],
enabled: server.connection.status() === "connected",
queryFn: () => server.api.config.get({ location: { directory: location().directory } }),
}))
const configured = createMemo(() =>
configuredLanguageServers(config.isPending || config.isError ? [] : (config.data ?? [])),
)
const empty = () => !config.isPending && !config.isError && configured().servers.length === 0
onCleanup(
server.event.on("config.updated", (event) => {
if (event.location && event.location.directory !== location().directory) return
void config.refetch()
}),
)
return (
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<div class="project-settings-extension-section-copy">
<span>
{language.t(
empty()
? configured().disabled
? "project.settings.extensions.lsp.disabled.title"
: "project.settings.extensions.lsp.empty.title"
: "project.settings.extensions.lsp.configured",
)}
</span>
<Show when={empty()}>
<span class="project-settings-extension-empty-description">
{language.t(
configured().disabled
? "project.settings.extensions.lsp.disabled.description"
: "project.settings.extensions.lsp.empty.description",
)}
</span>
</Show>
</div>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show
when={!config.isPending}
fallback={<p class="project-settings-extension-empty-description">{language.t("common.loading")}</p>}
>
<Show
when={!config.isError}
fallback={
<div class="project-settings-extension-section-copy" role="status">
<span class="project-settings-extension-empty-description">
{language.t("project.settings.extensions.lsp.loadFailed")}
</span>
<Button variant="ghost-muted" onClick={() => void config.refetch()}>
{language.t("project.settings.extensions.lsp.retry")}
</Button>
</div>
}
>
<Show when={configured().servers.length > 0}>
<ExtensionCard>
<For each={configured().servers}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name}
description={item.extensions.length ? <bdi dir="ltr">{item.extensions.join(", ")}</bdi> : undefined}
>
<span class="project-settings-extension-row-status">
{language.t(
item.disabled
? "project.settings.extensions.lsp.status.disabled"
: "project.settings.extensions.lsp.status.enabled",
)}
</span>
</ExtensionRow>
)}
</For>
</ExtensionCard>
</Show>
</Show>
</Show>
</div>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -288,77 +145,61 @@ export const ProjectSettingsExtensions: Component = () => {
)
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("project.settings.extensions.description")}
</span>
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
</Tabs.List>
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</div>
</div>
</Tabs.Content>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs settings-subtabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
<Tabs.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="mcps"
empty={projectMcpNames().length === 0}
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
/>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
</Tabs.Content>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="plugins"
empty={projectPlugins().length === 0}
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
/>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
</Tabs.Content>
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="skills"
empty={projectSkills().length === 0}
action={
<ExternalLink class="settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
}
/>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</Tabs.Content>
<Tabs.Content value="lsps">
<ProjectLanguageServers />
</Tabs.Content>
</Tabs>
</div>
</>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</Tabs.Content>
</Tabs>
</div>
)
}
@@ -1,65 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ConfigEntry } from "@opencode/client/promise"
import { configuredLanguageServers } from "./project-lsp"
const global: ConfigEntry = {
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
eslint: { disabled: true },
},
},
}
describe("configuredLanguageServers", () => {
test("merges inherited entries and project overrides by name", () => {
expect(
configuredLanguageServers([
global,
{ type: "directory", path: "/project/.opencode" },
{
type: "document",
path: "/project/opencode.jsonc",
info: {
lsp: {
typescript: { disabled: true },
eslint: { command: ["eslint-lsp"], disabled: false, extensions: [".js"] },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]),
).toEqual({
disabled: false,
servers: [
{ name: "eslint", disabled: false, extensions: [".js"] },
{ name: "rust", disabled: false, extensions: [".rs"] },
{ name: "typescript", disabled: true, extensions: [".ts", ".tsx"] },
],
})
})
test("does not invent servers for omitted or boolean-only configuration", () => {
expect(configuredLanguageServers([])).toEqual({ disabled: false, servers: [] })
expect(configuredLanguageServers([global, { type: "document", info: { lsp: false } }])).toEqual({
disabled: true,
servers: [],
})
expect(configuredLanguageServers([global, { type: "document", info: { lsp: true } }])).toEqual({
disabled: false,
servers: [],
})
})
test("allows named configuration after a disabled parent without reviving earlier entries", () => {
expect(
configuredLanguageServers([
global,
{ type: "document", info: { lsp: false } },
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
]),
).toEqual({ disabled: false, servers: [{ name: "custom", disabled: false, extensions: [] }] })
})
})
@@ -1,28 +0,0 @@
import type { ConfigEntry } from "@opencode/client/promise"
type ConfiguredServer = { name: string; disabled: boolean; extensions: readonly string[] }
export function configuredLanguageServers(entries: readonly ConfigEntry[]) {
const state = entries.reduce(
(state, entry) => {
if (entry.type !== "document" || entry.info.lsp === undefined) return state
const config = entry.info.lsp
// A boolean replaces the object form, so earlier named entries no longer apply.
if (typeof config === "boolean") return { disabled: !config, servers: new Map<string, ConfiguredServer>() }
Object.entries(config).forEach(([name, server]) => {
const previous = state.servers.get(name)
state.servers.set(name, {
name,
disabled: server.disabled ?? previous?.disabled ?? false,
extensions: ("extensions" in server ? server.extensions : undefined) ?? previous?.extensions ?? [],
})
})
return { disabled: false, servers: state.servers }
},
{ disabled: false, servers: new Map<string, ConfiguredServer>() },
)
return {
disabled: state.disabled,
servers: [...state.servers.values()].sort((a, b) => a.name.localeCompare(b.name)),
}
}
@@ -1,17 +1,14 @@
import { getFilename } from "@opencode/util/path"
import type { ProjectUpdateInput } from "@opencode/client/promise"
import { useDialog } from "@opencode/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import type { LocalProject } from "@/shell/state/layout"
import { type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { showToast } from "@/shell/notifications/toast"
type ProjectPatch = Pick<ProjectUpdateInput, "name" | "icon" | "commands">
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
@@ -23,62 +20,8 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
saving: 0,
})
const saved = {
name: props.project.name ?? "",
startup: store.startup.trim(),
color: store.color,
iconOverride: store.iconOverride,
}
let iconInput: HTMLInputElement | undefined
let queue = Promise.resolve()
const persist = (patch: ProjectPatch, complete: () => void) => {
setStore("saving", (value) => value + 1)
queue = queue
.then(async () => {
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, ...patch })
serverCtx().sync.project.update(project)
return
}
serverCtx().sync.project.meta(props.project.worktree, patch)
})
.then(complete)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
})
.finally(() => setStore("saving", (value) => value - 1))
}
const saveName = () => {
const value = store.name.trim() === folderName() ? "" : store.name.trim()
if (value === saved.name) return
persist({ name: value }, () => {
saved.name = value
})
}
const saveStartup = () => {
const value = store.startup.trim()
if (value === saved.startup) return
persist({ commands: { start: value } }, () => {
saved.startup = value
})
}
const saveIcon = (color = store.color, override = store.iconOverride) => {
if (color === saved.color && override === saved.iconOverride) return
persist({ icon: { color: color ?? "", override: override ?? "" } }, () => {
saved.color = color
saved.iconOverride = override
})
}
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
@@ -88,46 +31,85 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
saveIcon(store.color, result)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
serverCtx().sync.project.update(project)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
saveName,
saveStartup,
setColor(value: string | undefined) {
setStore("color", value)
saveIcon(value, store.iconOverride)
},
drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
},
dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
},
dragLeave() {
setStore("dragOver", false)
},
inputChange(input: HTMLInputElement) {
const file = input.files?.[0]
if (file) selectFile(file)
},
iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
saveIcon(store.color, "")
return
}
iconInput?.click()
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
@@ -1,299 +0,0 @@
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-settings-extension-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-extension-section-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
color: var(--text-strong);
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
}
.project-settings-extension-empty-description {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.project-settings-extension-row-description {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
overflow-wrap: anywhere;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
line-height: var(--line-height-compact);
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.project-settings-shared-trigger {
width: fit-content;
align-self: flex-start;
}
.project-settings-shared-label {
display: flex;
align-items: center;
gap: 6px;
}
.project-settings-shared-chevron {
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
transform: rotate(-90deg);
}
.project-settings-shared-chevron.open {
transform: rotate(0deg);
}
[dir="rtl"] .project-settings-shared-chevron {
transform: rotate(90deg);
}
[dir="rtl"] .project-settings-shared-chevron.open {
transform: rotate(0deg);
}
.project-settings-shared-count {
display: inline-flex;
min-width: 16px;
height: 16px;
align-items: center;
justify-content: center;
padding-inline: 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-name {
width: min(280px, 100%);
}
.project-settings-server-link {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.project-settings-server-link:hover,
.project-settings-server-link:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.project-settings-server-link:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.project-settings-icon {
position: relative;
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
overflow: hidden;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
background: transparent;
cursor: pointer;
}
.project-settings-icon--active,
.project-settings-icon:focus-visible {
outline-color: var(--v2-border-border-focus);
}
.project-settings-icon-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: color-mix(in srgb, var(--v2-background-bg-contrast) 80%, transparent);
color: var(--v2-icon-icon-contrast);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.project-settings-icon-overlay.visible {
opacity: 1;
}
.project-settings-colors {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.project-settings-color {
position: relative;
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
outline: none;
background: transparent;
transition: box-shadow 120ms ease;
}
.project-settings-color:focus-visible,
.project-settings-color--selected {
box-shadow:
0 0 0 2px var(--v2-background-bg-layer-01),
0 0 0 4px var(--v2-border-border-focus);
}
@media (hover: hover) {
.project-settings-color:not(.project-settings-color--selected):hover {
box-shadow:
0 0 0 2px var(--v2-background-bg-layer-01),
0 0 0 4px var(--v2-border-border-strong);
}
}
.project-settings-color-check {
position: absolute;
color: var(--v2-icon-icon-contrast);
}
.project-settings-startup {
display: flex;
flex-direction: column;
gap: 12px;
padding-block: 20px;
}
.project-settings-startup-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-settings-startup-title {
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-startup-description,
.project-settings-startup-hint {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-startup-hint code {
padding-inline: 4px;
border-radius: 4px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-base);
font-family: var(--font-family-mono);
font-size: 12px;
unicode-bidi: isolate;
}
@media (prefers-reduced-motion: reduce) {
.project-settings-icon-overlay,
.project-settings-color,
.project-settings-shared-chevron {
transition: none;
}
}
@@ -1,167 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
import { Textarea } from "@opencode/ui/textarea"
import { TextInput } from "@opencode/ui/text-input"
import { For, Show, type Component } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useSettingsServers } from "@/settings/servers/inventory"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { createEditProjectModel } from "./project-model"
import "./project.css"
export const SettingsProjectGeneral: Component<{
project: LocalProject
server: ServerConnection.Any
onOpenServer: () => void
}> = (props) => {
const language = useLanguage()
const model = createEditProjectModel(props)
const servers = useSettingsServers()
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
</div>
</div>
</div>
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
<SettingsList>
<SettingsRow
title={language.t("project.settings.name.title")}
description={language.t("project.settings.name.description")}
>
<div class="project-settings-name">
<TextInput
type="text"
appearance="base"
value={model.store.name}
placeholder={model.folderName()}
aria-label={language.t("project.settings.name.title")}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
onBlur={model.saveName}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("dialog.project.edit.icon")}
description={language.t("project.settings.icon.description")}
>
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="project-settings-icon"
classList={{ "project-settings-icon--active": model.store.dragOver }}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={(event) => model.drop(event)}
onDragOver={(event) => model.dragOver(event)}
onDragLeave={() => model.dragLeave()}
onClick={() => model.iconClick()}
>
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-8 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
/>
<span classList={{ "project-settings-icon-overlay": true, visible: model.store.iconHover }}>
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={(event) => model.inputChange(event.currentTarget)}
/>
</SettingsRow>
<Show when={!model.store.iconOverride}>
<SettingsRow
title={language.t("dialog.project.edit.color")}
description={language.t("project.settings.color.description")}
>
<div class="project-settings-colors">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => {
const selected = () => getProjectAvatarVariant(model.store.color) === color
return (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={selected()}
class="project-settings-color"
classList={{ "project-settings-color--selected": selected() }}
onClick={() => model.setColor(selected() ? undefined : color)}
>
<ProjectAvatar
fallback=""
variant={color}
class="!size-5 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
<Show when={selected()}>
<Icon name="check" size="small" class="project-settings-color-check" />
</Show>
</button>
)
}}
</For>
</div>
</SettingsRow>
</Show>
<div class="project-settings-startup">
<div class="project-settings-startup-copy">
<span class="project-settings-startup-title">{language.t("dialog.project.edit.worktree.startup")}</span>
<span class="project-settings-startup-description">
{language.t("project.settings.worktree.startup.description")}
</span>
</div>
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
aria-label={language.t("dialog.project.edit.worktree.startup")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
onBlur={model.saveStartup}
/>
<div class="project-settings-startup-hint flex flex-col">
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.base"))}</span>
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.new"))}</span>
</div>
</div>
</SettingsList>
</div>
</>
)
}
function inlineVariables(text: string) {
return text
.split(/(\$[A-Z][A-Z0-9_]*)/g)
.map((part, index) => (index % 2 === 0 ? part : <code dir="ltr">{part}</code>))
}
+116 -32
View File
@@ -1,20 +1,69 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButton } from "@opencode/ui/icon-button"
import { Icon } from "@opencode/ui/icon"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { InlineServerSelect } from "@/settings/server-select"
import { DialogEditProject } from "./project-dialog"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
onOpenProject: (project: LocalProject) => void
}> = (props) => {
export const SettingsProjects: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const projects = createMemo(() => global.ensureServerCtx(props.server).projects.list())
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const multiple = createMemo(() => global.servers.list().length > 1)
const projects = createMemo(() => {
const server = selected()
if (!server) return []
return global.ensureServerCtx(server).projects.list()
})
type ProjectItem = ReturnType<typeof projects>[number]
const groups = createMemo(() =>
global.servers
.list()
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
.filter((group) => group.projects.length > 0),
)
const openProjectSettings = (project: ProjectItem, server = selected()) => {
if (!server) return
dialog.push(() => <DialogEditProject project={project} server={server} />)
}
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
return (
<div
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButton
type="button"
variant="ghost-muted"
size="small"
icon={<Icon name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
}}
/>
</div>
</div>
)
}
return (
<>
@@ -24,39 +73,74 @@ export const SettingsProjects: Component<{
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Show when={multiple()}>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</Show>
</div>
</div>
<div class="settings-tab-body">
<Show
when={projects().length > 0}
when={allServers()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
<div class="flex flex-col gap-2 w-full">
<Show
when={projects().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<Show when={selected()} keyed>
{(server) => (
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
</div>
</div>
)}
</Show>
</Show>
</div>
}
>
<div class="flex w-full flex-col gap-2">
<For each={projects()}>
{(project) => (
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</For>
<div class="flex flex-col gap-8 w-full">
<Show
when={groups().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</div>
@@ -1,52 +0,0 @@
import { queryOptions, useQueryClient, type QueryClient } from "@tanstack/solid-query"
import type { Accessor } from "solid-js"
import type { ServerSDK } from "@/runtime/server/client"
import type { ServerConnection } from "@/runtime/server/registry"
import { useServerCtx, type ServerCtx } from "@/runtime/server/runtime"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
function workspaceProjectsQuery(sdk: ServerSDK) {
return queryOptions({
queryKey: [sdk.scope, "settings-workspace-project-metadata"],
queryFn: () => sdk.api.project.list(),
staleTime: 30_000,
})
}
export function workspaceInventoryQuery(context: ServerCtx, client: QueryClient, projectID?: string) {
return queryOptions({
queryKey: [context.sdk.scope, "settings-workspace-inventory", projectID ?? null],
queryFn: async () =>
Promise.all(
(await client.fetchQuery(workspaceProjectsQuery(context.sdk)))
.filter((project) => projectID === undefined || project.id === projectID)
.map(async (project) => {
const worktrees = (await context.sync.worktrees.load(project.canonical)) ?? [
{ directory: project.canonical },
...project.sandboxes.map((directory) => ({ directory })),
]
return normalizeProjectInfo({ ...project, worktrees })
}),
),
staleTime: 30_000,
})
}
export function useWorkspacesPrefetch(
server: Accessor<ServerConnection.Any | undefined>,
projectID?: Accessor<string | undefined>,
) {
const client = useQueryClient()
const context = useServerCtx(server)
return () => {
const current = context()
if (!current || current.sdk.connection.status() !== "connected") return
const project = projectID?.()
if (project) {
void client.prefetchQuery(workspaceInventoryQuery(current, client, project))
return
}
// Server-level hover warms metadata without booting every project's Location.
void client.prefetchQuery(workspaceProjectsQuery(current.sdk))
}
}
@@ -4,7 +4,7 @@ import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode/client/promise"
import { useQuery, useQueryClient } from "@tanstack/solid-query"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
@@ -14,13 +14,14 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { getRelativeTime } from "@/shell/time"
import { sessionLabel } from "@/session/title"
import { pathKey } from "@/workspaces/path-key"
import { worktreeInventoryKey } from "@/workspaces/inventory"
import { SettingsList } from "@/settings/list"
import { InlineServerSelect } from "@/settings/server-select"
import { useTabs } from "@/shell/tabs/tabs"
import { usePlatform } from "@/runtime/platform/platform"
import { clearWorkspaceTerminals } from "@/session/terminal/context"
@@ -39,7 +40,7 @@ import {
} from "@/workspaces/paths"
import { listAllSessions } from "@/session/list"
import type { ServerScope } from "@/runtime/server/scope"
import { workspaceInventoryQuery } from "./queries"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import "@/settings/settings.css"
type Workspace = {
@@ -47,17 +48,13 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{
activeDirectory?: string
resetProjectFilter?: () => number
projectID?: string
}> = (props) => {
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
props,
) => {
const dialog = useDialog()
const language = useLanguage()
const server = useServer()
const serverSDK = server.ctx.sdk
const queryClient = useQueryClient()
const data = server.ctx.data
const serverSDK = useServerSDK()
const data = useData()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
@@ -67,18 +64,23 @@ export const SettingsWorkspaces: Component<{
removing: [] as string[],
})
createEffect(() => {
if (props.projectID) {
setStore("project", props.projectID)
return
}
props.resetProjectFilter?.()
props.resetProjectFilter()
setStore("project", "all")
})
const projectQuery = useQuery(() => ({
...workspaceInventoryQuery(server.ctx, queryClient, props.projectID),
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
enabled: serverSDK.connection.status() === "connected",
refetchOnMount: true,
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ location: { directory: project.canonical } })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
),
refetchOnMount: "always",
}))
const inventory = createMemo(() => (projectQuery.isPending ? [] : (projectQuery.data ?? [])))
const workspaces = createMemo(() => workspaceInventory(inventory()))
@@ -89,11 +91,7 @@ export const SettingsWorkspaces: Component<{
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
props.projectID
? props.projectID
: store.project === "all" || projects().some((project) => project.id === store.project)
? store.project
: "all",
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
@@ -127,10 +125,7 @@ export const SettingsWorkspaces: Component<{
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
const sessions = mergeWorkspaceSessionInventory(
sessionQuery.isPending ? [] : (sessionQuery.data ?? []),
data.session.list(),
)
const sessions = sessionQuery.isPending ? [] : (sessionQuery.data ?? [])
return new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
@@ -140,13 +135,13 @@ export const SettingsWorkspaces: Component<{
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const workspacesWithoutSessions = createMemo(() => {
if (sessionQuery.isPending || sessionQuery.isError || sessionQuery.isPlaceholderData) return []
if (sessionQuery.isPending || sessionQuery.isError) return []
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
})
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
if (!count && sessionQuery.isPending) return language.t("session.messages.loading")
if (!count && sessionQuery.isError) return language.t("common.requestFailed")
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
const project = projectName(workspace.project)
const label = language.plural("settings.workspaces.sessions", count, {
@@ -242,10 +237,7 @@ export const SettingsWorkspaces: Component<{
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await queryClient.invalidateQueries({
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.worktree),
})
await queryClient.invalidateQueries({ queryKey: [context.sdk.scope, "settings-workspace-inventory"] })
await projectQuery.refetch()
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
@@ -336,17 +328,18 @@ export const SettingsWorkspaces: Component<{
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-section-title">
<span class="settings-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={!props.projectID && projects().length > 1}>
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
+10 -11
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -306,20 +306,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => {
if (!catalogReady()) return
batch(() =>
registered().forEach((opt) => {
if (!opt.title) return
setCatalog(
actionId(opt.id),
reconcile({
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
if (opt.title)
acc[id] = {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}),
)
}),
}
return acc
}, {} as CommandCatalog),
)
})

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