mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 10:56:22 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c88ed24f | ||
|
|
8e648913e3 | ||
|
|
e28fcf5edb |
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+5
-5
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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 server’s 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 project’s 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")
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? []))
|
||||
}
|
||||
@@ -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">"{store.filter}"</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>))
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user