mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 20:06:24 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f34df601 | ||
|
|
bec2e6c6ce | ||
|
|
1ad0442c39 | ||
|
|
7c5a4d01aa | ||
|
|
4143084250 | ||
|
|
c7868ce0e6 | ||
|
|
cf4f1fb45e | ||
|
|
0a21042523 | ||
|
|
6add96698e | ||
|
|
3f579f53f4 | ||
|
|
9f3ba44c4a | ||
|
|
c4fe0f676a | ||
|
|
625c469854 | ||
|
|
d6c22b3bf5 | ||
|
|
bbf4cd4975 | ||
|
|
27027777d2 | ||
|
|
5d1841e0aa | ||
|
|
2253d4c31d | ||
|
|
877b04f0b9 | ||
|
|
41e5d1b6b6 | ||
|
|
60fb97d59e | ||
|
|
b2e6e764a8 | ||
|
|
0d8e64112f | ||
|
|
b0b85cbb77 |
@@ -24,11 +24,16 @@ jobs:
|
||||
- name: Run stats script
|
||||
run: bun script/stats.ts
|
||||
|
||||
- name: Update GitHub stars
|
||||
run: bun script/update-github-stars.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Commit stats
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add STATS.md
|
||||
git add STATS.md packages/console/app/src/config.ts packages/stats/app/src/routes/stats-shell.tsx
|
||||
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
|
||||
git push
|
||||
env:
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"core-js": "3.50.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
@@ -111,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -215,7 +216,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -251,7 +252,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -278,7 +279,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -295,7 +296,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -319,7 +320,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -339,7 +340,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
@@ -411,7 +412,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -463,7 +464,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -500,7 +501,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -516,7 +517,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -535,7 +536,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -548,7 +549,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -562,7 +563,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -577,7 +578,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -616,7 +617,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -646,7 +647,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -661,7 +662,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -685,7 +686,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -706,7 +707,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -728,7 +729,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -763,7 +764,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -783,7 +784,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -817,7 +818,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -836,7 +837,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -882,7 +883,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -896,7 +897,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -931,7 +932,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -966,7 +967,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -999,7 +1000,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1040,7 +1041,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3702,6 +3703,8 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
|
||||
|
||||
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
|
||||
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
|
||||
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
|
||||
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
|
||||
"x86_64-linux": "sha256-QJn59dTbxspHl35vGxs6akynlfaWaPqvbMNsi0EqnbM=",
|
||||
"aarch64-linux": "sha256-wa54uUIAth/ZOyejZ/TfU0zZdkhNirwq7yK1218jhBQ=",
|
||||
"aarch64-darwin": "sha256-VvsfVdtxcVijCvlrWpBUkgmkYFoXX7QiXM0Fhz1t2VE=",
|
||||
"x86_64-darwin": "sha256-IxYFBG5I+znXcTVqrAWKhG2qvVtjM+Amp68xIGKZogE="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -106,7 +106,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
|
||||
const BedrockToolSpec = Schema.Struct({
|
||||
toolSpec: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
inputSchema: Schema.Struct({
|
||||
json: JsonObject,
|
||||
}),
|
||||
@@ -222,7 +222,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
|
||||
inputSchema: { json: inputSchema },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,7 +23,13 @@ export interface DispatchResult extends ToolSettlement {
|
||||
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
|
||||
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
|
||||
const tool = tools[name]
|
||||
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
|
||||
if (!tool)
|
||||
return Effect.succeed(
|
||||
result(call, {
|
||||
type: "error",
|
||||
value: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`,
|
||||
}),
|
||||
)
|
||||
if (!tool.execute)
|
||||
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
|
||||
|
||||
|
||||
@@ -279,6 +279,52 @@ describe("Bedrock Converse route", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
;["", " \t\r\n"].forEach((description) => {
|
||||
it.effect(`omits blank tool description ${JSON.stringify(description)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig.tools).toEqual([
|
||||
{
|
||||
toolSpec: {
|
||||
name: "lookup",
|
||||
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(prepared.body.toolConfig.tools[0].toolSpec).not.toHaveProperty("description")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves meaningful tool descriptions including surrounding whitespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const description = " \tLookup data.\n"
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig.tools[0].toolSpec.description).toBe(description)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -237,13 +237,16 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolError({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
message: "Unknown tool: missing",
|
||||
message: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
result: { type: "error", value: "Unknown tool: missing" },
|
||||
result: {
|
||||
type: "error",
|
||||
value: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
|
||||
},
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
@@ -712,12 +715,17 @@ describe("LLMClient tools", () => {
|
||||
|
||||
const toolError = events.find(LLMEvent.is.toolError)
|
||||
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
|
||||
expect(toolError?.message).toContain("Unknown tool")
|
||||
expect(toolError?.message).toBe(
|
||||
'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
|
||||
)
|
||||
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
type: "tool-result",
|
||||
id: "call_1",
|
||||
name: "missing_tool",
|
||||
result: { type: "error", value: "Unknown tool: missing_tool" },
|
||||
result: {
|
||||
type: "error",
|
||||
value: 'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
// Safari 16.6 has neither API. Remove them before the web entry runs.
|
||||
Reflect.deleteProperty(Map, "groupBy")
|
||||
Reflect.deleteProperty(Promise, "withResolvers")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Add project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(row).toBeVisible()
|
||||
})
|
||||
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+;")
|
||||
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
const toggle = dialog.getByRole("switch")
|
||||
await expect(toggle).not.toBeChecked()
|
||||
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
|
||||
state.status = "disabled"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/figma-desktop/connect") {
|
||||
state.status = state.fail ? "failed" : "connected"
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
const panel =
|
||||
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
if (surface === "popover") {
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
|
||||
const toggle = panel.getByRole("switch")
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await expect(toggle).toBeChecked()
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
}
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
.getByRole("listitem", { includeHidden: true })
|
||||
.filter({ has: page.getByText("Request failed", { exact: true }) })
|
||||
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeChecked({ checked: surface === "popover" })
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
|
||||
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
|
||||
await expect(toast).toBeHidden()
|
||||
state.fail = false
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
if (surface === "popover") {
|
||||
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
}
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
+5
-4
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(drawer.getByRole("button", { 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
|
||||
@@ -0,0 +1,476 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
const directory = "/workspace/summary-project"
|
||||
const workspace = "/workspace/existing-worktree"
|
||||
const createdWorkspace = "/workspace/created-worktree"
|
||||
const draftID = "draft_summary"
|
||||
const secondDraftID = "draft_summary_other"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
|
||||
for (const rtl of [false, true]) {
|
||||
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const mock = await openDraft(page, "main", { direction: rtl ? "rtl" : "ltr" })
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", rtl ? "rtl" : "ltr")
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
if (!button || !view) return false
|
||||
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
|
||||
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const project = await summary.locator('[data-section="project"]').boundingBox()
|
||||
const server = await summary.locator('[data-section="server"]').boundingBox()
|
||||
if (!button || !project || !server) return
|
||||
return {
|
||||
top: project.y - button.y - button.height,
|
||||
cards: server.y - project.y - project.height,
|
||||
}
|
||||
})
|
||||
.toEqual({ top: 12, cards: 8 })
|
||||
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const [name, item] of [
|
||||
["MCP", "summary-mcp"],
|
||||
["Plugins", "project-plugin"],
|
||||
["Skills", "summary-skill"],
|
||||
["LSP", "typescript"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const menu = await worktreeMenu.boundingBox()
|
||||
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
|
||||
if (!menu || !panel) return false
|
||||
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
|
||||
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
|
||||
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await mcp.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("non-Git folders show their status without offering worktree actions", async ({ page }) => {
|
||||
await openDraft(page, "main", { git: false })
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByText("No Git", { exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
|
||||
await expect(summary.getByRole("button", { name: "New worktree", exact: true })).toHaveCount(0)
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "MCP", exact: true }).getByRole("switch", { name: "summary-mcp", exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
|
||||
await expect(menu).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
|
||||
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
|
||||
)?.mcp?.states,
|
||||
),
|
||||
)
|
||||
.toEqual({ "summary-mcp": false })
|
||||
|
||||
await page.goto(`/new-session?draftId=${secondDraftID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.goto(draftPath)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "worktree", directory },
|
||||
{ type: "mcp", directory: createdWorkspace, enabled: false },
|
||||
{ type: "session", directory: createdWorkspace },
|
||||
{ type: "prompt", directory: createdWorkspace },
|
||||
])
|
||||
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
})
|
||||
|
||||
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
try {
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "mcp", directory, enabled: false },
|
||||
{ type: "session", directory },
|
||||
{ type: "prompt", directory },
|
||||
])
|
||||
})
|
||||
|
||||
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
mock.state.holdDirectory = directory
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
try {
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.state.fail = true
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
|
||||
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
|
||||
mock.state.fail = false
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(mock.status.get(createdWorkspace)).toBe("disabled")
|
||||
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
|
||||
})
|
||||
|
||||
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.status.set(createdWorkspace, "needs_auth")
|
||||
const attempts: string[] = []
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
return route.fulfill({
|
||||
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: createdWorkspace },
|
||||
data: {
|
||||
id: "summary-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
|
||||
expect(attempts).toEqual([createdWorkspace])
|
||||
expect(mock.prompts).toEqual([])
|
||||
mock.status.set(createdWorkspace, "connected")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
name: "summary-project",
|
||||
vcs: options.git === false ? undefined : "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const sessions: ReturnType<typeof currentSession>[] = []
|
||||
const status = new Map<string, string>([
|
||||
[directory, "connected"],
|
||||
[workspace, "disabled"],
|
||||
[createdWorkspace, "connected"],
|
||||
])
|
||||
const calls: { type: string; directory: string; enabled?: boolean }[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "summary-model" },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt(input) {
|
||||
const session = sessions.find((session) => session.id === input.sessionID)
|
||||
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
|
||||
calls.push({ type: "prompt", directory: session.location.directory })
|
||||
prompts.push(input)
|
||||
},
|
||||
})
|
||||
if (options.git === false) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/vcs",
|
||||
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
|
||||
)
|
||||
}
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
if (route.request().method() === "POST") {
|
||||
const enabled = url.pathname.endsWith("/connect")
|
||||
calls.push({ type: "mcp", directory: target, enabled })
|
||||
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
|
||||
status.set(target, enabled ? "connected" : "disabled")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "summary-mcp",
|
||||
integrationID: "summary-oauth",
|
||||
status: { status: status.get(target) ?? "connected" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/location",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: project.id, directory, canonical: directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
(route) => {
|
||||
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
|
||||
const id = target === directory ? "project-plugin" : "workspace-plugin"
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/skill",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "summary-skill",
|
||||
name: "summary-skill",
|
||||
location: "/skills/summary/SKILL.md",
|
||||
content: "Summary skill",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
calls.push({ type: "worktree", directory })
|
||||
project.sandboxes.push(createdWorkspace)
|
||||
return route.fulfill({ json: { directory: createdWorkspace } })
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
|
||||
calls.push({ type: "session", directory: body.location.directory })
|
||||
const session = currentSession(
|
||||
{ ...body, projectID: project.id, title: "Created summary session" },
|
||||
body.location.directory,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session } })
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
if (options.direction) await openWithDirection(page, draftPath, options.direction)
|
||||
if (!options.direction) await page.goto(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
|
||||
if (options.git === false) await expect(page.getByText("No Git", { exact: true })).toBeVisible()
|
||||
if (options.git !== false) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
|
||||
).toBeVisible()
|
||||
}
|
||||
return { calls, prompts, status, state }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { ProjectUpdateInput } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/project-autosave"
|
||||
const project = {
|
||||
id: "proj_autosave",
|
||||
canonical: directory,
|
||||
name: "Autosave project",
|
||||
icon: { color: "orange" },
|
||||
commands: { start: "echo setup" },
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
for (const field of [
|
||||
{
|
||||
name: "color",
|
||||
label: "",
|
||||
initial: "orange",
|
||||
next: "blue",
|
||||
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
label: "Project name",
|
||||
initial: project.name,
|
||||
next: "Renamed project",
|
||||
patches: [{ name: "Renamed project" }, { name: project.name }],
|
||||
},
|
||||
{
|
||||
name: "startup script",
|
||||
label: "Worktree startup script",
|
||||
initial: project.commands.start,
|
||||
next: "bun install",
|
||||
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
|
||||
},
|
||||
]) {
|
||||
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
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.addInitScript(
|
||||
({ directory, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [{ type: "http", http: { url: server } }],
|
||||
projects: {
|
||||
local: [{ worktree: directory, expanded: true }],
|
||||
[server]: [{ worktree: directory, expanded: true }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, server },
|
||||
)
|
||||
const pending = Promise.withResolvers<void>()
|
||||
const patches: unknown[] = []
|
||||
await page.route(`**/api/project/${project.id}`, async (route) => {
|
||||
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
|
||||
patches.push(patch)
|
||||
if (patches.length === 1) await pending.promise
|
||||
await route.fulfill({ json: { ...project, ...patch } })
|
||||
})
|
||||
await page.goto("/settings")
|
||||
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 edit = async (value: string) => {
|
||||
if (field.name === "color") {
|
||||
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
|
||||
return
|
||||
}
|
||||
const input = settings.getByRole("textbox", { name: field.label, exact: true })
|
||||
await input.fill(value)
|
||||
await input.blur()
|
||||
}
|
||||
|
||||
const first = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
|
||||
)
|
||||
await edit(field.next)
|
||||
await first
|
||||
await edit(field.initial)
|
||||
expect(patches).toEqual([field.patches[0]])
|
||||
pending.resolve()
|
||||
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
|
||||
expect(patches).toEqual(field.patches)
|
||||
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: project.name, exact: true }).click()
|
||||
if (field.name === "color") {
|
||||
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
)
|
||||
return
|
||||
}
|
||||
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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,6 +20,7 @@ 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)
|
||||
|
||||
@@ -34,8 +35,10 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
|
||||
await expect(settings.getByRole("complementary")).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()
|
||||
@@ -63,9 +66,21 @@ 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}`)
|
||||
@@ -73,7 +88,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: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", 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}`)
|
||||
@@ -311,6 +326,7 @@ type MockServerOptions = {
|
||||
listFailures?: Record<string, number>
|
||||
// Records /api/session/:id GETs so tests can assert session resyncs.
|
||||
sessionGets?: string[]
|
||||
preferencesUnavailable?: boolean
|
||||
}
|
||||
|
||||
async function mockServers(
|
||||
@@ -402,6 +418,11 @@ 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" } })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { expect, test, type Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -13,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -58,6 +60,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
searches.push(input)
|
||||
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||
},
|
||||
events: () => events.splice(0),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
@@ -126,6 +129,35 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
const missingReadPattern = "**/api/fs/read/README.md*"
|
||||
const missingRead = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
_tag: "FileNotFoundError",
|
||||
path: "README.md",
|
||||
message: "File not found: README.md",
|
||||
}),
|
||||
})
|
||||
await page.route(missingReadPattern, missingRead)
|
||||
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
|
||||
events.push(filesystemEvent("README.md", "unlink"))
|
||||
await missingResponse
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
|
||||
const missingTab = panel.getByRole("tab", { name: "File not found: README.md", selected: true })
|
||||
await expect(missingTab).toBeVisible()
|
||||
await expect(missingTab.locator("[data-file-not-found]")).toHaveCSS("text-decoration-line", "line-through")
|
||||
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await page.unroute(missingReadPattern, missingRead)
|
||||
events.push(filesystemEvent("README.md", "add"))
|
||||
const restoredTab = panel.getByRole("tab", { name: "README.md", selected: true })
|
||||
await expect(restoredTab).toBeVisible()
|
||||
await expect(restoredTab.locator("[data-file-not-found]")).toHaveCount(0)
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
@@ -155,6 +187,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
})
|
||||
|
||||
function filesystemEvent(file: string, event: "add" | "change" | "unlink"): OpenCodeEvent {
|
||||
return {
|
||||
id: `evt_${file}_${event}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
location: { directory },
|
||||
data: { file, event },
|
||||
}
|
||||
}
|
||||
|
||||
function fileNode(path: string) {
|
||||
return {
|
||||
name: path,
|
||||
|
||||
@@ -27,8 +27,12 @@ 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)
|
||||
await settings.getByRole("tab", { name: "Servers" }).click()
|
||||
await settings.getByRole("button", { name: "Add server" }).click()
|
||||
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()
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect(status).toHaveCount(0)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await details.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
await expect(mcp).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await page.keyboard.press("Enter")
|
||||
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,
|
||||
)
|
||||
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)
|
||||
await expect(menu).toBeHidden()
|
||||
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).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 expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
@@ -272,12 +273,13 @@ 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 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()
|
||||
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()
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const server = "http://summary-remote.test:4096"
|
||||
const path = "/home/remote/.config/opencode/opencode.jsonc"
|
||||
|
||||
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
for (const service of [
|
||||
{ name: "MCP", config: { mcp: { servers: {} } } },
|
||||
{ name: "Plugins", config: { plugins: [] } },
|
||||
{ name: "Skills", config: { skills: [] } },
|
||||
{ name: "LSP", config: { lsp: false } },
|
||||
]) {
|
||||
test(`remote ${service.name} copies its configuration path with timeline copy feedback`, async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.route("**/api/config**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ type: "document", path, info: service.config },
|
||||
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
|
||||
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
|
||||
],
|
||||
})
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Session details", exact: true })
|
||||
.getByRole("button", { name: service.name, exact: true })
|
||||
.click()
|
||||
const menu = page.getByRole("dialog", { name: service.name, exact: true })
|
||||
const copy = menu.getByRole("button", { name: "Copy configuration file path", exact: true })
|
||||
const tooltipOffset = async () => {
|
||||
const icon = await copy.locator("svg").boundingBox()
|
||||
const tooltip = await page.getByRole("tooltip").boundingBox()
|
||||
if (!icon || !tooltip) return Infinity
|
||||
return Math.abs(tooltip.x + tooltip.width / 2 - icon.x - icon.width / 2)
|
||||
}
|
||||
await expect(copy).toBeEnabled()
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
|
||||
await expect(copy.locator(".session-service-config-arrow")).toHaveCount(0)
|
||||
await copy.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Copy")
|
||||
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
|
||||
await copy.click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(path)
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Copied")
|
||||
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-check")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
|
||||
})
|
||||
}
|
||||
|
||||
test("remote configuration without a file path reports the problem instead of copying a directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setup(page)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
|
||||
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Session details", exact: true })
|
||||
.getByRole("button", { name: "Skills", exact: true })
|
||||
.click()
|
||||
const copy = page
|
||||
.getByRole("dialog", { name: "Skills", exact: true })
|
||||
.getByRole("button", { name: "Copy configuration file path", exact: true })
|
||||
await copy.click()
|
||||
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
|
||||
await expect(copy).toBeEnabled()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("original clipboard")
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
|
||||
})
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
server,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.addInitScript((server) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [{ type: "http", http: { url: server }, displayName: "Remote server" }],
|
||||
projects: {},
|
||||
lastProject: {},
|
||||
}),
|
||||
)
|
||||
}, server)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`summary slides the conversation into spare space and back in ${direction}`, async ({ page }, testInfo) => {
|
||||
await page.clock.install({ time: new Date("2026-09-10T12:00:00Z") })
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await mockStressTimeline(page)
|
||||
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const row = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const content = page.locator("[data-timeline-virtual-content]")
|
||||
const composer = page.locator('[data-component="session-composer-dock"] > div')
|
||||
const panel = page.locator('[data-slot="session-chat-panel"]')
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(row).toBeInViewport()
|
||||
await expect(row).toHaveCSS("width", "1000px")
|
||||
const before = await row.boundingBox()
|
||||
const dock = await composer.boundingBox()
|
||||
expect(before).not.toBeNull()
|
||||
expect(dock).not.toBeNull()
|
||||
await content.evaluate((element) => {
|
||||
element.setAttribute("data-summary-motion", "")
|
||||
for (const type of ["transitionrun", "transitionend"]) {
|
||||
element.addEventListener(type, (event) => {
|
||||
if (event.target !== element || (event as TransitionEvent).propertyName !== "translate") return
|
||||
element.setAttribute("data-summary-motion", `${element.getAttribute("data-summary-motion")}${type},`)
|
||||
})
|
||||
}
|
||||
})
|
||||
await testInfo.attach(`summary-${direction}-centered`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await trigger.click()
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const message = await row.boundingBox()
|
||||
const details = await summary.boundingBox()
|
||||
const chat = await panel.boundingBox()
|
||||
if (!message || !details || !chat) return false
|
||||
return (
|
||||
message.x >= chat.x &&
|
||||
message.x + message.width <= chat.x + chat.width &&
|
||||
(direction === "ltr" ? message.x + message.width < details.x : message.x > details.x + details.width)
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(row).toHaveCSS("width", "1000px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const message = await row.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
if (!message || !input || !before || !dock) return Infinity
|
||||
return Math.abs(message.x - before.x - (input.x - dock.x))
|
||||
})
|
||||
.toBeLessThan(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
content.evaluate((element) => element.parentElement!.scrollWidth - element.parentElement!.clientWidth),
|
||||
)
|
||||
.toBe(0)
|
||||
await testInfo.attach(`summary-${direction}-shifted`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
await page.clock.pauseAt(new Date("2026-09-10T12:01:00Z"))
|
||||
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
|
||||
for (const width of [1520, 1280, 1600]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
|
||||
await page.clock.runFor(100)
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
await expect(composer).toHaveCSS("translate", shifted)
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "")
|
||||
}
|
||||
await page.clock.resume()
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect(content).not.toHaveCSS("translate", shifted)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
|
||||
await expect(trigger).toBeFocused()
|
||||
|
||||
await trigger.click()
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
|
||||
const shell = 1440 - (await panel.boundingBox())!.width
|
||||
await page.setViewportSize({ width: 1320 + shell, height: 900 })
|
||||
await expect.poll(async () => (await panel.boundingBox())!.width).toBe(1320)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const message = (await row.boundingBox())!
|
||||
const details = (await summary.boundingBox())!
|
||||
return direction === "ltr" ? details.x - message.x - message.width : message.x - details.x - details.width
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
await page.setViewportSize({ width: 1319 + shell, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect(row).toHaveCSS("width", "1000px")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", "0px")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(content).toHaveCSS("transition-duration", "0s")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`summary truncates long copy before the indicator column in ${direction}`, async ({ page }) => {
|
||||
const branch = `feature/${"long-branch-name-".repeat(12)}`
|
||||
await mockStressTimeline(page)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/vcs",
|
||||
(route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: { location: { directory: fixture.directory }, data: { branch: { current: branch, default: "main" } } },
|
||||
})
|
||||
},
|
||||
)
|
||||
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const text = summary.getByText(branch, { exact: true })
|
||||
await expect(text).toBeVisible()
|
||||
await expect(text).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
const arrow = summary
|
||||
.getByRole("button", { name: "Local repository", exact: true })
|
||||
.locator(".session-summary-menu-indicator")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const label = await text.boundingBox()
|
||||
const icon = await arrow.boundingBox()
|
||||
if (!label || !icon) return 0
|
||||
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
|
||||
})
|
||||
.toBeGreaterThanOrEqual(12)
|
||||
for (const name of ["Local repository", "MCP", "Plugins", "Skills", "LSP"]) {
|
||||
const row = summary.getByRole("button", { name, exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const label = await row.locator(".session-summary-label").boundingBox()
|
||||
const icon = await row.locator(".session-summary-menu-indicator").boundingBox()
|
||||
if (!label || !icon) return 0
|
||||
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
|
||||
})
|
||||
.toBeGreaterThanOrEqual(12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
|
||||
}, theme)
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
new URL(route.request().url()).pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
|
||||
status: { status: "connected" },
|
||||
})),
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
|
||||
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
|
||||
const summary = page.locator('[data-component="session-summary-panel"]')
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch")).toHaveCount(30)
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await menu.boundingBox()
|
||||
return (
|
||||
!!bounds &&
|
||||
bounds.x >= 15 &&
|
||||
bounds.y >= 15 &&
|
||||
bounds.x + bounds.width <= 785 &&
|
||||
bounds.y + bounds.height <= 585
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
|
||||
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { enabled: true }
|
||||
const writes: string[] = []
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (route.request().method() === "POST") {
|
||||
expect(directory).toBe(fixture.directory)
|
||||
writes.push(url.pathname)
|
||||
state.enabled = url.pathname.endsWith("/connect")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
|
||||
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
|
||||
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
|
||||
{ name: "waiting", status: { status: "pending" } },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
|
||||
const row = submenu
|
||||
.locator('[data-component="switch"]')
|
||||
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
|
||||
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
|
||||
"Sign in required",
|
||||
)
|
||||
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
|
||||
const enabled = index % 2 !== 0
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (target === "label") await row.getByText("figma", { exact: true }).click()
|
||||
if (target === "dot") await row.locator(".session-service-dot").click()
|
||||
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
|
||||
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
|
||||
if (target === "keyboard") await toggle.press("Space")
|
||||
await expect(toggle).toBeChecked({ checked: enabled })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(submenu).toBeVisible()
|
||||
if (target === "keyboard") await expect(toggle).toBeFocused()
|
||||
expect(writes).toHaveLength(index + 1)
|
||||
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
|
||||
}
|
||||
})
|
||||
|
||||
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { status: "disabled" }
|
||||
const attempts: string[] = []
|
||||
const resources = Promise.withResolvers<void>()
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.endsWith("/connect")) {
|
||||
state.status = "needs_auth"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(route.request().url())
|
||||
return route.fulfill({
|
||||
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: {
|
||||
id: "linear-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
const refresh = page.waitForRequest(
|
||||
(request) =>
|
||||
state.status === "needs_auth" &&
|
||||
new URL(request.url()).pathname === "/api/mcp/resource" &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
try {
|
||||
const popup = page.waitForEvent("popup")
|
||||
await submenu.getByText("linear", { exact: true }).click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await refresh
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toHaveAccessibleDescription("Sign in required")
|
||||
} finally {
|
||||
resources.resolve()
|
||||
}
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
|
||||
})
|
||||
|
||||
test("multiple desktop connections show the session's server name", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
const current = { type: "http", http: { url: server }, displayName: "Design server" }
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
hidden: {},
|
||||
lastProject: {},
|
||||
recentlyClosed: {},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
const services = [
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured", item: "summary-mcp" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured", item: "summary-plugin" },
|
||||
{ name: "Skills", path: "/api/skill", empty: "No skills configured", item: "summary-skill" },
|
||||
{ name: "LSP", path: "/api/config", empty: "No LSP servers configured", item: "summary-lsp" },
|
||||
] as const
|
||||
|
||||
for (const service of services) {
|
||||
for (const empty of [false, true]) {
|
||||
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
const state = { hold: false }
|
||||
const response = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === service.path,
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (state.hold) await response.promise
|
||||
if (service.name === "LSP")
|
||||
return route.fulfill({
|
||||
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
|
||||
})
|
||||
const items =
|
||||
service.name === "MCP"
|
||||
? [{ name: service.item, status: { status: "connected" } }]
|
||||
: service.name === "Plugins"
|
||||
? [
|
||||
{
|
||||
id: service.item,
|
||||
source: { type: "package", target: service.item },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
|
||||
},
|
||||
)
|
||||
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const trigger = summary.getByRole("button", { name: service.name, exact: true })
|
||||
await trigger.click()
|
||||
const menu = page.getByRole("dialog", { name: service.name, exact: true })
|
||||
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
|
||||
if (empty) {
|
||||
const message = menu.locator(".session-service-empty")
|
||||
await expect(message).toHaveCSS("padding", "0px")
|
||||
await expect(message).toHaveCSS("gap", "0px")
|
||||
await expect(message.locator("strong")).toHaveCSS("padding", "8px 12px")
|
||||
await expect(message).toHaveCSS("font-size", "13px")
|
||||
await expect(message).toHaveCSS("line-height", "16px")
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
|
||||
await expect(message.locator(".session-service-footer")).toHaveCSS("font-weight", "440")
|
||||
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
state.hold = true
|
||||
const refresh = page.waitForRequest(
|
||||
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
|
||||
)
|
||||
try {
|
||||
await trigger.click()
|
||||
await refresh
|
||||
await expect(menu).toHaveAttribute("aria-busy", "true")
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu.getByRole("status")).toHaveCount(0)
|
||||
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(content).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
const response = Promise.withResolvers<void>()
|
||||
const state = { requested: false }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
state.requested = true
|
||||
await response.promise
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
try {
|
||||
await expect.poll(() => state.requested).toBe(true)
|
||||
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(menu.getByRole("status")).toContainText("Loading")
|
||||
await expect(menu.getByText("No plugins configured", { exact: true })).toHaveCount(0)
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "../performance/timeline/timeline-test-helpers"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const custom of [false, true]) {
|
||||
test(`summary tooltip and ${custom ? "custom" : "default"} shortcut follow the active session`, async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
if (custom) {
|
||||
await page.addInitScript(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, keybinds: { ...settings.keybinds, "session.summary.toggle": "f8" } }),
|
||||
)
|
||||
})
|
||||
}
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await trigger.hover()
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip).toContainText("Summary")
|
||||
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
|
||||
const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y"
|
||||
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
|
||||
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
|
||||
)
|
||||
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(id)}"]`).click()
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${id === fixture.sourceID ? fixture.expected.sourceMessageIDs.at(-1) : fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
),
|
||||
).toBeInViewport()
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
await expect(tooltip).toBeHidden()
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const layout of ["horizontal", "vertical"] as const) {
|
||||
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((layout) => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
appearance: { ...settings.appearance, tabLayout: layout },
|
||||
general: { ...settings.general, showStatus: true },
|
||||
}),
|
||||
)
|
||||
}, layout)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const server = summary.getByRole("button", { name: "Extensions", exact: true })
|
||||
await expect(project).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
for (const heading of [project, server]) {
|
||||
await expect(heading).toHaveCSS("column-gap", "8px")
|
||||
await expect(heading.locator(".session-summary-label")).toHaveCSS("flex-grow", "0")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "14")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "14")
|
||||
}
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await project.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
await server.click()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
|
||||
.toEqual({ projectExpanded: false, serverExpanded: false })
|
||||
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await trigger.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "false")
|
||||
await server.click()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await mcp.hover()
|
||||
await expect(submenu).toHaveCount(0)
|
||||
await mcp.click()
|
||||
await expect(submenu.getByText("No MCP servers configured", { exact: true })).toBeVisible()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await mcp.boundingBox()
|
||||
const menu = await submenu.boundingBox()
|
||||
if (!row || !menu) return false
|
||||
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(submenu).toBeHidden()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(mcp).toBeFocused()
|
||||
await mcp.press("Enter")
|
||||
await expect(submenu.getByText("Configuration file")).toBeVisible()
|
||||
await mcp.click()
|
||||
await expect(submenu).toBeHidden()
|
||||
|
||||
for (const [name, text] of [
|
||||
["Plugins", "No plugins configured"],
|
||||
["Skills", "No skills configured"],
|
||||
["LSP", "No LSP servers configured"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
|
||||
await expect(submenu).toBeHidden()
|
||||
}
|
||||
await summary.getByRole("button", { name: "Extensions", exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
|
||||
for (const reviewOpen of [false, true]) {
|
||||
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const header = await page.locator("[data-session-title]").boundingBox()
|
||||
const panel = await summary.boundingBox()
|
||||
if (!header || !panel) return Infinity
|
||||
return direction === "ltr"
|
||||
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
|
||||
: Math.abs(panel.x - header.x - 12)
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { fail: true, extra: false }
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/plugin**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
|
||||
{
|
||||
id: "supermemory",
|
||||
source: { type: "package", target: "opencode-supermemory" },
|
||||
features: { server: true },
|
||||
state: { status: "active" },
|
||||
},
|
||||
{
|
||||
id: "broken-plugin",
|
||||
source: { type: "local", path: "/broken.ts" },
|
||||
features: { server: true },
|
||||
state: { status: "failed", error: "Plugin failed to activate" },
|
||||
},
|
||||
...(state.extra
|
||||
? [
|
||||
{
|
||||
id: "daytona",
|
||||
source: { type: "package", target: "opencode-daytona" },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/skill**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
|
||||
{
|
||||
id: "review-animations",
|
||||
name: "review-animations",
|
||||
location: "/skills/review/SKILL.md",
|
||||
content: "Review animations",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/config**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
rust: { command: ["rust-analyzer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
|
||||
],
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(plugins.getByRole("alert")).toContainText("Request failed")
|
||||
await expect(plugins.getByText("No plugins configured", { exact: true })).toHaveCount(0)
|
||||
state.fail = false
|
||||
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
|
||||
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
|
||||
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
|
||||
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
state.extra = true
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Skills", exact: true }).click()
|
||||
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
|
||||
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
|
||||
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
|
||||
await expect(plugins).toBeHidden()
|
||||
await summary.getByRole("button", { name: "LSP", exact: true }).click()
|
||||
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
|
||||
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
|
||||
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
|
||||
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
@@ -13,11 +13,20 @@ 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`,
|
||||
@@ -56,6 +65,131 @@ 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()
|
||||
|
||||
@@ -38,7 +38,7 @@ for (const viewport of [
|
||||
test("every settings page leaves room below its final content", async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
|
||||
const panel = settings.locator(".settings-content > .settings-panel:visible")
|
||||
if (viewport.bottom) {
|
||||
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
@@ -50,12 +50,12 @@ for (const viewport of [
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Shortcuts",
|
||||
"Servers",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
"Server",
|
||||
"Experimental",
|
||||
"About",
|
||||
]) {
|
||||
|
||||
@@ -69,11 +69,8 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await panel.getByText("rebase", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
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 })
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import en from "../../src/runtime/i18n/en"
|
||||
import { clientSettings } from "../../src/settings/search-catalog"
|
||||
import { createMockServerHandler, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const directory = "/projects/opencode"
|
||||
const config = {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_search",
|
||||
canonical: directory,
|
||||
name: "OpenCode",
|
||||
icon: { color: "orange" },
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
function projectList(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...config.project,
|
||||
id: `proj_search_${index}`,
|
||||
canonical: `${directory}-${index}`,
|
||||
name: `OpenCode ${String(index).padStart(2, "0")}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function ui(page: Page) {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
return {
|
||||
settings,
|
||||
search: settings.getByRole("combobox", { name: "Search", exact: true }),
|
||||
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
|
||||
viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"),
|
||||
}
|
||||
}
|
||||
|
||||
async function findShortcut(page: Page) {
|
||||
// Browser emulation can report a different OS than the machine running Playwright.
|
||||
const key = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f"))
|
||||
await page.keyboard.press(key)
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, config)
|
||||
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
|
||||
await page.goto("/")
|
||||
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const view = ui(page)
|
||||
await expect(view.settings).toBeFocused()
|
||||
// Readiness includes the server-backed project inventory, not just the settings shell.
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.search.clear()
|
||||
})
|
||||
|
||||
test("pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("font")
|
||||
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
|
||||
const terminal = view.results.getByRole("option", { name: "Terminal Font, Appearance", exact: true })
|
||||
const font = view.results.getByRole("option", { name: "UI Font, Appearance", exact: true })
|
||||
await expect(view.results.getByRole("option")).toHaveText([
|
||||
"Code FontAppearance",
|
||||
"Terminal FontAppearance",
|
||||
"UI FontAppearance",
|
||||
])
|
||||
await code.click()
|
||||
await expect(code).toBeFocused()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Code Font", exact: true })).toBeInViewport()
|
||||
await code.press("ArrowDown")
|
||||
await expect(terminal).toBeFocused()
|
||||
await expect(terminal).toHaveAttribute("aria-selected", "true")
|
||||
await terminal.press("Enter")
|
||||
await expect(terminal).toBeFocused()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
|
||||
await terminal.press("End")
|
||||
await expect(font).toBeFocused()
|
||||
await font.press("Home")
|
||||
await expect(code).toBeFocused()
|
||||
await terminal.focus()
|
||||
await expect(terminal).toHaveAttribute("aria-selected", "true")
|
||||
await terminal.press("Enter")
|
||||
await expect(view.settings.locator('[data-search-target="row"]')).toContainText("Terminal Font")
|
||||
await findShortcut(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
expect(await view.search.evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd])).toEqual([
|
||||
0, 4,
|
||||
])
|
||||
await view.settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(view.settings).toBeHidden()
|
||||
await findShortcut(page)
|
||||
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeFocused()
|
||||
})
|
||||
|
||||
test("page priority, compact icons, section context, and the minimal empty state", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("work")
|
||||
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
|
||||
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
|
||||
await expect(pageResult).toHaveCSS("height", "28px")
|
||||
await expect(pageResult.locator("svg")).toHaveCount(1)
|
||||
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
|
||||
await view.search.fill("Agent")
|
||||
await expect(view.results.getByRole("option", { name: "Agent, Desktop notifications", exact: true })).toHaveText(
|
||||
"AgentDesktop notifications",
|
||||
)
|
||||
await expect(view.results.getByRole("option", { name: "Agent, Sound effects", exact: true })).toHaveText(
|
||||
"AgentSound effects",
|
||||
)
|
||||
await view.search.fill("zzzzzzzzzz")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("status")).toHaveText('No results for "zzzzzzzzzz"')
|
||||
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await view.search.press("Escape")
|
||||
await expect(view.search).toHaveValue("")
|
||||
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
const query = "zzzz 🧑🏽💻 ".repeat(40)
|
||||
await view.search.fill(query)
|
||||
const status = view.settings.getByRole("status")
|
||||
const quoted = status.locator("bdi")
|
||||
await expect(status).toHaveAccessibleName(`No results for "${query}"`)
|
||||
await expect(quoted).toHaveText(/^".+…"$/)
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const text = await quoted.textContent()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(quoted).toHaveText(/^".+…"$/)
|
||||
await expect(quoted).not.toHaveText(text!)
|
||||
await expect
|
||||
.poll(() =>
|
||||
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
|
||||
)
|
||||
.toBe(true)
|
||||
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
|
||||
|
||||
await view.search.fill("zzzzzzzzzz")
|
||||
await expect(status).toHaveText('No results for "zzzzzzzzzz"')
|
||||
await view.search.pressSequentially("x")
|
||||
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
|
||||
})
|
||||
|
||||
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of [
|
||||
{ tab: "Models", search: "Search models" },
|
||||
{ tab: "Shortcuts", search: "Search shortcuts" },
|
||||
{ tab: "Models", search: "Search models" },
|
||||
]) {
|
||||
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
|
||||
}
|
||||
await view.search.fill("shortcuts")
|
||||
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
|
||||
await result.click()
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
for (const count of [7, 8]) {
|
||||
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
await page.route("**/api/project", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fulfill({ json: projectList(8) })
|
||||
})
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
|
||||
await page.reload()
|
||||
await requested
|
||||
const view = ui(page)
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
try {
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await expect(search).toHaveCount(0)
|
||||
} finally {
|
||||
inventory.resolve()
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
await view.search.fill(en[entry.label as keyof typeof en])
|
||||
const result = view.results.locator(`[data-setting-target="${entry.target}"]`)
|
||||
await expect(result).toHaveCount(1)
|
||||
await result.click()
|
||||
await expect(view.settings.locator(`[data-action="${entry.target}"]`)).toBeInViewport()
|
||||
}
|
||||
})
|
||||
|
||||
test("qualified project results preserve query and selection on return", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("project name")
|
||||
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
|
||||
await view.search.fill("OpenCode")
|
||||
const project = view.results.getByRole("option")
|
||||
await expect(project).toHaveText("OOpenCode")
|
||||
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px")
|
||||
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
|
||||
await view.search.fill("OpenCode name")
|
||||
const name = view.results.getByRole("option")
|
||||
await expect(name).toHaveText("Project nameGeneral")
|
||||
await name.click()
|
||||
await expect(view.search).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode", exact: true })).toHaveCSS("line-height", "20px")
|
||||
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.search).toHaveValue("OpenCode name")
|
||||
await expect(name).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("returning from a project restores a scrolled result list", async ({ page }) => {
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({
|
||||
json: projectList(30),
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(30)
|
||||
const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]')
|
||||
await view.viewport.hover()
|
||||
await expect(scrollbar).toHaveAttribute("data-visible", "true")
|
||||
const bounds = (await scrollbar.boundingBox())!
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
await page.mouse.down()
|
||||
await expect(scrollbar).toHaveAttribute("data-dragging", "true")
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 })
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
|
||||
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
|
||||
await project.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
|
||||
const scroll = await view.viewport.evaluate((list) => list.scrollTop)
|
||||
await project.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll)
|
||||
await view.search.fill("about")
|
||||
await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible()
|
||||
await expect(scrollbar).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("IME confirmation does not activate a search result", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("font")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(3)
|
||||
await view.search.dispatchEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })
|
||||
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await view.search.press("Enter")
|
||||
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("header highlight runs once per search activation and finishes cleanly", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.settings.evaluate((root) => {
|
||||
root.setAttribute("data-search-flashes", "0")
|
||||
root.addEventListener("animationstart", (event) => {
|
||||
if (!(event instanceof AnimationEvent) || event.animationName !== "settings-search-reveal") return
|
||||
root.setAttribute("data-search-flashes", String(Number(root.getAttribute("data-search-flashes")) + 1))
|
||||
})
|
||||
})
|
||||
await view.search.fill("skills")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("tab", { name: "Skills", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
|
||||
await view.settings.evaluate(async (root) => {
|
||||
await Promise.all(
|
||||
root
|
||||
.getAnimations({ subtree: true })
|
||||
.filter(
|
||||
(animation) => animation instanceof CSSAnimation && animation.animationName === "settings-search-reveal",
|
||||
)
|
||||
.map((animation) => animation.finished),
|
||||
)
|
||||
})
|
||||
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
|
||||
for (const tab of ["MCPs", "Plugins", "Skills"]) {
|
||||
await view.settings.getByRole("tab", { name: tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("tab", { name: tab, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
|
||||
}
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "2")
|
||||
await view.search.fill("about")
|
||||
await view.results.getByRole("option", { name: "About", exact: true }).click()
|
||||
await expect(view.settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "3")
|
||||
})
|
||||
|
||||
test("multi-server results navigate to the named server and hide search in nested views", async ({ page }) => {
|
||||
const server = "http://127.0.0.1:4097"
|
||||
const remote = createMockServerHandler({
|
||||
...config,
|
||||
directory: "/remote/opencode",
|
||||
project: { ...config.project, canonical: "/remote/opencode" },
|
||||
})
|
||||
page.on("close", () => void remote.dispose())
|
||||
await installSseTransport(page, { server })
|
||||
await page.route(`${server}/api/**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS")
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
|
||||
})
|
||||
const response = await remote.handler(
|
||||
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
|
||||
)
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
(server) =>
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
|
||||
),
|
||||
server,
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("MCPs")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(2)
|
||||
await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click()
|
||||
await expect(view.search).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await expect(view.search).toHaveValue("MCPs")
|
||||
await view.search.fill("Build server models")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await view.search.fill("Build server OpenCode name")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test.describe(`search layout ${direction}`, () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
colorScheme: direction === "rtl" ? "dark" : "light",
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
})
|
||||
test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.evaluate((direction) => {
|
||||
document.documentElement.dir = direction
|
||||
}, direction)
|
||||
await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30))
|
||||
await view.search.press("End")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(view.search).not.toHaveCSS("mask-image", "none")
|
||||
|
||||
await view.search.press("Home")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
|
||||
await view.search.evaluate((input: HTMLInputElement, direction) => {
|
||||
input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1)
|
||||
}, direction)
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
|
||||
|
||||
await view.search.fill("z".repeat(50))
|
||||
await expect(view.search).not.toHaveCSS("mask-image", "none")
|
||||
await page.setViewportSize({ width: 600, height: 844 })
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(view.search).toHaveCSS("mask-image", "none")
|
||||
|
||||
await view.search.fill("zzzz ".repeat(30))
|
||||
await expect(view.search).not.toHaveCSS("mask-image", "none")
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(view.search).toHaveValue("")
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(view.search).toHaveCSS("mask-image", "none")
|
||||
})
|
||||
|
||||
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await page.evaluate((direction) => {
|
||||
document.documentElement.dir = direction
|
||||
}, direction)
|
||||
const input = await view.search.boundingBox()
|
||||
const content = await view.settings.locator(".settings-content").boundingBox()
|
||||
await view.search.fill("e")
|
||||
await expect.poll(() => view.search.boundingBox()).toEqual(input)
|
||||
await expect.poll(() => view.settings.locator(".settings-content").boundingBox()).toEqual(content)
|
||||
await view.search.fill("terminal font")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.results).toBeHidden()
|
||||
await expect(view.search).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(view.search).not.toHaveAttribute("aria-activedescendant", /.+/)
|
||||
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
|
||||
await findShortcut(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.results).toBeVisible()
|
||||
await expect(view.search).toHaveAttribute("aria-activedescendant", /.+/)
|
||||
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
await expect(status).toHaveCount(0)
|
||||
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,7 +473,7 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
|
||||
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
|
||||
await expect(projectNameSwitch).toBeChecked()
|
||||
await expect(projectNames).toHaveText(["tab-project"])
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
|
||||
await expect(settings.getByRole("complementary")).toHaveCSS("width", "240px")
|
||||
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "../../src/app"
|
||||
import { useLanguage, type Direction } from "../../src/runtime/i18n/language"
|
||||
import { PlatformProvider } from "../../src/runtime/platform/platform"
|
||||
import { createWebPlatform } from "../../src/runtime/platform/web"
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
|
||||
export function mount(input: { server: string; route: string; direction: Direction }) {
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: input.route, replace: true, scroll: false })
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: input.server } }
|
||||
|
||||
function DirectedApp() {
|
||||
const language = useLanguage()
|
||||
language.setDirection(input.direction)
|
||||
return (
|
||||
<AppInterface
|
||||
servers={[server]}
|
||||
defaultServer={ServerConnection.key(server)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
router={(props) => <MemoryRouter {...props} history={history} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={createWebPlatform("test").platform}>
|
||||
<AppBaseProviders locale="en">
|
||||
<DirectedApp />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
||||
>
|
||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||
<script type="module">
|
||||
import { mount } from "./app-direction.fixture.tsx"
|
||||
const query = new URLSearchParams(location.search)
|
||||
mount({ server: query.get("server"), route: query.get("route"), direction: query.get("direction") })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export async function openWithDirection(page: Page, route: string, direction: "ltr" | "rtl") {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/e2e/utils/app-direction.html?${new URLSearchParams({ server, route, direction })}`)
|
||||
}
|
||||
@@ -70,7 +70,23 @@ 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,
|
||||
|
||||
@@ -6,9 +6,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
|
||||
|
||||
export interface MockServerConfig {
|
||||
server?: string
|
||||
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>)[]
|
||||
@@ -47,7 +51,9 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const server =
|
||||
config.server ??
|
||||
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
@@ -185,13 +191,14 @@ export function createMockServerHandler(config: MockServerConfig) {
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, 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", () => {
|
||||
@@ -269,12 +276,29 @@ 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 },
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
import { mount } from "./settings-wsl.fixture.tsx"
|
||||
const query = new URLSearchParams(location.search)
|
||||
mount({ server: query.get("server"), mode: query.get("mode") })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -84,6 +84,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"core-js": "3.50.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
|
||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
||||
reuseExistingServer: !built,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_TEST_FIXTURES: "1",
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @refresh reload
|
||||
|
||||
import "@/runtime/polyfills"
|
||||
import { init } from "@sentry/solid"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -27,6 +28,7 @@ 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()
|
||||
@@ -131,8 +133,9 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
})
|
||||
},
|
||||
edit: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
|
||||
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
|
||||
settings.openProject({
|
||||
server: ServerConnection.key(conn),
|
||||
project: project.worktree,
|
||||
})
|
||||
},
|
||||
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
|
||||
@@ -38,6 +38,38 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-chat-panel"] {
|
||||
container-name: session-chat;
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
|
||||
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"][data-summary-resizing="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
|
||||
Move only as far as needed; wider panels already have enough space in the margin. */
|
||||
@container session-chat (min-width: 1320px) {
|
||||
[data-slot="session-chat-panel"][data-summary-open="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
--session-summary-translate: min(0px, calc(50cqi - 820px));
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-summary-translate: max(0px, calc(820px - 50cqi));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
|
||||
[data-slot="session-timeline-scroll"]
|
||||
> .scroll-view__thumb {
|
||||
|
||||
@@ -17,12 +17,14 @@ import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
worktree: () => string
|
||||
branch: () => string | undefined
|
||||
submitted: () => void
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const route = useSessionKey()
|
||||
const prompt = useComposerState()
|
||||
@@ -49,6 +51,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
const projectDirectory = location().directory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
const id = Session.ID.create()
|
||||
const pending =
|
||||
worktree === "create"
|
||||
@@ -68,6 +71,18 @@ export function createNewSessionComposerAdapter(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const rollback = async () => {
|
||||
if (!pending) return
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
}
|
||||
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
|
||||
await rollback()
|
||||
if (pending) props.mcp.remember(sessionDirectory, mcp)
|
||||
return
|
||||
}
|
||||
|
||||
const created = data.session.create({
|
||||
id,
|
||||
agent: selection.agent,
|
||||
@@ -89,10 +104,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
},
|
||||
)
|
||||
if (pending && !(await creation).ok) {
|
||||
// Keep retries on the worktree that was already created, not another new checkout.
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
await rollback()
|
||||
return
|
||||
}
|
||||
const afterCreation = async <T>(run: () => Promise<T>) => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
|
||||
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
|
||||
const tabs = useTabs()
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore<{
|
||||
preparing: boolean
|
||||
pending: Record<string, Promise<boolean> | undefined>
|
||||
}>({ preparing: false, pending: {} })
|
||||
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
|
||||
const target = createMemo(() => key(input.worktree()))
|
||||
const preview = () => input.worktree() === "create"
|
||||
const directory = createMemo(() => {
|
||||
const selected = input.worktree()
|
||||
return selected === "main" || selected === "create" ? location().directory : selected
|
||||
})
|
||||
const states = createMemo(() => {
|
||||
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
|
||||
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
|
||||
})
|
||||
const toggle = useMcpToggle(directory)
|
||||
const controls: McpControls = {
|
||||
get preview() {
|
||||
return preview()
|
||||
},
|
||||
get states() {
|
||||
return states()
|
||||
},
|
||||
get pending() {
|
||||
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
|
||||
},
|
||||
change(name, enabled) {
|
||||
if (controls.pending) return
|
||||
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
|
||||
if (preview()) return
|
||||
const current = directory()
|
||||
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
setStore("pending", current, request)
|
||||
void request.finally(() => setStore("pending", current, undefined))
|
||||
},
|
||||
}
|
||||
|
||||
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
|
||||
const pending = store.pending[directory]
|
||||
if (pending && !(await pending)) return false
|
||||
const entries = Object.entries(states)
|
||||
if (entries.length === 0) return true
|
||||
const catalog = await sdk.api.mcp.list({ location: { directory } })
|
||||
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
|
||||
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
|
||||
const results = await Promise.all(
|
||||
entries
|
||||
.filter(([name, enabled]) => {
|
||||
const server = catalog.data.find((server) => server.name === name)
|
||||
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
|
||||
})
|
||||
.map(([name, enabled]) =>
|
||||
toggle.mutateAsync({ name, enabled, directory }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (results.some((success) => !success)) return false
|
||||
const current = await sdk.api.mcp.list({ location: { directory } })
|
||||
const unresolved = entries.find(([name, enabled]) => {
|
||||
const status = current.data.find((server) => server.name === name)?.status.status
|
||||
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
|
||||
})
|
||||
if (!unresolved) return true
|
||||
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
|
||||
throw new Error(
|
||||
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
|
||||
name: unresolved[0],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
controls,
|
||||
directory,
|
||||
capture: () => ({ ...states() }),
|
||||
remember(directory: string, states: Readonly<Record<string, boolean>>) {
|
||||
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
|
||||
},
|
||||
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
|
||||
if (!store.pending[directory] && !Object.keys(states).length) return true
|
||||
setStore("preparing", true)
|
||||
return apply(directory, states)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.summary.mcp.prepareFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
})
|
||||
.finally(() => setStore("preparing", false))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
|
||||
@@ -1,25 +1,34 @@
|
||||
import { createPromptProjectController } from "@/new-session/project/selector"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
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"
|
||||
import { useComposerCommands } from "@/composer/commands"
|
||||
import { createNewSessionComposerAdapter } from "./composer-adapter"
|
||||
import { NewSessionStatus, NewSessionView } from "./view"
|
||||
import { NewSessionView } from "./view"
|
||||
import { createNewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { useNewSessionCommands } from "./commands"
|
||||
import { createDraftMcpControls } from "./mcp"
|
||||
|
||||
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
|
||||
export default function NewSessionPage(props: { draftId: string }) {
|
||||
const settings = useSettings()
|
||||
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const tabs = useTabs()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const servers = useSettingsServers()
|
||||
const settingsSurface = useSettingsSurface()
|
||||
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,
|
||||
@@ -31,11 +40,13 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
|
||||
const composer = createNewSessionComposerAdapter({
|
||||
draftID: props.draftId,
|
||||
worktree: workspace.selection.value,
|
||||
branch: workspace.bar.branch,
|
||||
submitted: workspace.selection.remember,
|
||||
mcp,
|
||||
})
|
||||
const model = createComposerModel(composer.adapter)
|
||||
useComposerCommands({ model: composer.model })
|
||||
@@ -72,9 +83,8 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ProjectSummaryCard } from "@/session/summary/project-card"
|
||||
import { SessionServerPanel } from "@/session/summary/server-panel"
|
||||
import type { PromptProject } from "./project/selector"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { PromptWorkspaceSelector } from "./workspace/selector"
|
||||
|
||||
export function NewSessionSummary(props: {
|
||||
project?: PromptProject
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
shown: boolean
|
||||
onChooseProject: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-component="session-summary-panel">
|
||||
<Show
|
||||
when={props.project}
|
||||
fallback={
|
||||
<div class="session-summary-card">
|
||||
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
|
||||
<Icon name="folder" class="text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t("session.summary.chooseProject")}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(project) => (
|
||||
<>
|
||||
<ProjectSummaryCard project={project()}>
|
||||
<Show
|
||||
when={props.workspace.bar.visible()}
|
||||
fallback={
|
||||
<div class="session-summary-row">
|
||||
<Icon name="monitor" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t("session.new.git.none")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
variant="summary"
|
||||
value={props.workspace.selection.value()}
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branches={props.workspace.project.branches()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onChange={props.workspace.selection.set}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
</Show>
|
||||
</ProjectSummaryCard>
|
||||
<SessionServerPanel directory={props.mcp.directory()} shown={props.shown} mcp={props.mcp.controls} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
@@ -13,8 +14,6 @@ import {
|
||||
PromptProjectSelector,
|
||||
type PromptProjectController,
|
||||
} from "@/new-session/project/selector"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -23,6 +22,13 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
import { SummaryPopover } from "@/session/summary/popover"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
const NewSessionSummary = lazy(async () => {
|
||||
const { NewSessionSummary } = await import("./summary")
|
||||
return { default: NewSessionSummary }
|
||||
})
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -40,7 +46,9 @@ export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const [store, setStore] = createStore({ summary: false })
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
WorkspaceOnboardingSchema,
|
||||
@@ -61,6 +69,25 @@ export function NewSessionView(props: {
|
||||
active={props.composer.state.drag === "active"}
|
||||
input={props.composer.model.selection.current()?.capabilities.input}
|
||||
/>
|
||||
<div
|
||||
data-slot="new-session-summary"
|
||||
class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3"
|
||||
>
|
||||
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
|
||||
<Suspense>
|
||||
<NewSessionSummary
|
||||
project={props.project.selected()}
|
||||
workspace={props.workspace}
|
||||
mcp={props.mcp}
|
||||
shown={store.summary}
|
||||
onChooseProject={() => {
|
||||
setStore("summary", false)
|
||||
props.project.add()
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</SummaryPopover>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<NewSessionWordmark />
|
||||
@@ -115,19 +142,6 @@ export function NewSessionView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function NewSessionStatus(props: { visible: boolean }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<TitlebarRight>
|
||||
<Show when={props.visible}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
|
||||
@@ -15,13 +15,18 @@ export function PromptWorkspaceSelector(props: {
|
||||
branches: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
variant?: "inline" | "summary"
|
||||
onChange: (value: string) => void
|
||||
onCreate: (branch: string) => void
|
||||
onSearch: (search: string) => void
|
||||
onDone: () => void
|
||||
onDone?: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const summary = () => props.variant === "summary"
|
||||
const placement = createMemo(() =>
|
||||
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
|
||||
)
|
||||
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
@@ -58,17 +63,20 @@ export function PromptWorkspaceSelector(props: {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone()
|
||||
props.onDone?.()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (selected() === "main")
|
||||
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Show when={!summary()}>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
</Show>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
@@ -87,18 +95,28 @@ export function PromptWorkspaceSelector(props: {
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
class={summary() ? "min-w-0 w-full" : "min-w-0"}
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
|
||||
<Menu
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={24}
|
||||
modal={summary() ? false : undefined}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
@@ -106,7 +124,11 @@ export function PromptWorkspaceSelector(props: {
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-[200px]">
|
||||
@@ -233,22 +255,47 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Tooltip>
|
||||
<Show
|
||||
when={selected() === "create" && props.branch}
|
||||
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
|
||||
fallback={
|
||||
summary() ? (
|
||||
<Show when={props.branch}>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{props.branch}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
) : (
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<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">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "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={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
|
||||
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
|
||||
branch: props.branch!,
|
||||
})}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
|
||||
@@ -6,6 +6,13 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export type McpControls = {
|
||||
readonly preview: boolean
|
||||
readonly states: Readonly<Record<string, boolean>>
|
||||
readonly pending: boolean
|
||||
change: (name: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -17,31 +24,36 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
|
||||
}
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const ref = location()
|
||||
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
|
||||
const name = typeof input === "string" ? input : input.name
|
||||
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
|
||||
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
|
||||
if (!server || server.status.status === "pending") return
|
||||
if (server.status.status === "connected") {
|
||||
if (!server || (server.status.status === "pending" && typeof input === "string")) return
|
||||
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
|
||||
if (!enabled) {
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
|
||||
} else if (server.status.status === "needs_auth" && server.integrationID) {
|
||||
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
|
||||
}
|
||||
if (enabled && server.status.status !== "needs_auth") {
|
||||
await serverSDK.api.mcp.connect({ server: name, location: ref })
|
||||
}
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
await data.location.mcp.server.sync(ref)
|
||||
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === name)
|
||||
if (enabled && current?.status.status === "needs_auth" && current.integrationID) {
|
||||
const integration = await serverSDK.api.integration.get({ integrationID: current.integrationID, location: ref })
|
||||
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
|
||||
if (!method || method.type !== "oauth")
|
||||
throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
||||
if (!method || method.type !== "oauth") throw new Error(language.t("mcp.auth.interactiveForm", { name }))
|
||||
const attempt = await serverSDK.api.integration.oauth.connect({
|
||||
integrationID: server.integrationID,
|
||||
integrationID: current.integrationID,
|
||||
methodID: method.id,
|
||||
location: ref,
|
||||
})
|
||||
platform.openExternal(attempt.data.url)
|
||||
} else {
|
||||
await serverSDK.api.mcp.connect({ server: name, location: ref })
|
||||
}
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
data.location.mcp.resource.invalidate(ref)
|
||||
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
await Promise.all([data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
// A successful HTTP response can still leave the MCP connection in a failed state.
|
||||
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
|
||||
const status = current?.status
|
||||
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
|
||||
},
|
||||
onError: (error) =>
|
||||
|
||||
@@ -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-section-title">{group.items[0].provider.name}</span>
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
@@ -162,7 +162,7 @@ export const DialogManageModels: Component = () => {
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList>
|
||||
<SettingsList variant="catalog">
|
||||
<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-section-title">{group.items[0].provider.name}</span>
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={open()}>
|
||||
<SettingsList>
|
||||
<SettingsList variant="catalog">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
|
||||
@@ -116,6 +116,7 @@ export const dict = {
|
||||
"command.terminal.toggle": "Toggle terminal",
|
||||
"command.fileTree.toggle": "Toggle file tree",
|
||||
"command.review.toggle": "Toggle review",
|
||||
"command.session.summary.toggle": "Toggle summary",
|
||||
"command.terminal.new": "New terminal",
|
||||
"command.terminal.new.description": "Create a new terminal tab",
|
||||
"command.steps.toggle": "Toggle steps",
|
||||
@@ -387,6 +388,7 @@ export const dict = {
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
"mcp.status.disabled": "disabled",
|
||||
"mcp.auth.clickToAuthenticate": "Click to authenticate",
|
||||
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
|
||||
|
||||
"dialog.fork.empty": "No messages to fork from",
|
||||
|
||||
@@ -448,6 +450,7 @@ 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",
|
||||
@@ -617,6 +620,7 @@ export const dict = {
|
||||
"toast.model.none.description": "Connect a provider to summarize this session",
|
||||
|
||||
"toast.file.loadFailed.title": "Failed to load file",
|
||||
"file.error.notFound": "File not found: {{name}}",
|
||||
"toast.file.listFailed.title": "Failed to list files",
|
||||
|
||||
"toast.context.noLineSelection.title": "No line selection",
|
||||
@@ -996,6 +1000,20 @@ 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.search.placeholder": "Search",
|
||||
"settings.search.results": "Settings results",
|
||||
"settings.search.group.pages": "Pages",
|
||||
"settings.search.group.settings": "Settings",
|
||||
"settings.search.group.projects": "Projects",
|
||||
"settings.search.group.servers": "Servers",
|
||||
"settings.search.result": "{{title}}, {{scope}}, {{page}}",
|
||||
"settings.search.page": "{{title}}, {{scope}}",
|
||||
"settings.search.result.unscoped": "{{title}}, {{page}}",
|
||||
"settings.search.empty": "No results for {{query}}",
|
||||
"settings.search.empty.query": '"{{query}}"',
|
||||
"settings.search.refine": "Narrow your search to see more specific results.",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.preferences": "Preferences",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
@@ -1033,9 +1051,14 @@ 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",
|
||||
"settings.projects.search.placeholder": "Search projects",
|
||||
"settings.projects.server.all": "All servers",
|
||||
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||
"settings.extensions.description": "Manage extensions available on this server",
|
||||
@@ -1050,14 +1073,36 @@ 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",
|
||||
@@ -1383,8 +1428,41 @@ export const dict = {
|
||||
"workspace.lifecycle.moving": "Moving to worktree",
|
||||
"workspace.lifecycle.set": "Worktree set",
|
||||
"session.summary.title": "Session details",
|
||||
"session.summary.tooltip": "Summary",
|
||||
"session.summary.noBranch": "No branch",
|
||||
"session.summary.basedOn": "Based on {{branch}}",
|
||||
"session.summary.server": "Extensions",
|
||||
"session.summary.chooseProject": "Choose a project",
|
||||
"session.summary.mcp.onCreation": "Applies when the worktree is created",
|
||||
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
|
||||
"session.summary.mcp.unavailable": "MCP server {{name}} is not available in this worktree.",
|
||||
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
|
||||
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
|
||||
"session.summary.mcp": "MCP",
|
||||
"session.summary.mcp.title": "Configured MCP servers",
|
||||
"session.summary.plugins": "Plugins",
|
||||
"session.summary.plugins.configured": "Configured plugins",
|
||||
"session.summary.skills": "Skills",
|
||||
"session.summary.skills.configured": "Configured skills",
|
||||
"session.summary.lsp": "LSP",
|
||||
"session.summary.failed": "Failed",
|
||||
"session.summary.retry": "Retry",
|
||||
"session.summary.connecting": "Connecting…",
|
||||
"session.summary.needsAuth": "Sign in required",
|
||||
"session.summary.configure": "Configuration file",
|
||||
"session.summary.copyConfigPath": "Copy configuration file path",
|
||||
"session.summary.configFileMissing": "No configuration file found",
|
||||
"session.summary.mcp.empty": "No MCP servers configured",
|
||||
"session.summary.mcp.add": "Add servers in opencode.json",
|
||||
"session.summary.plugins.manage": "Manage plugins in opencode.json",
|
||||
"session.summary.plugins.empty": "No plugins configured",
|
||||
"session.summary.plugins.add": "Add plugins in opencode.json",
|
||||
"session.summary.skills.manage": "Manage skills in opencode.json",
|
||||
"session.summary.skills.empty": "No skills configured",
|
||||
"session.summary.skills.add": "Add skills in opencode.json",
|
||||
"session.summary.lsp.configured": "Configured LSPs",
|
||||
"session.summary.lsp.empty": "No LSP servers configured",
|
||||
"session.summary.lsp.manage": "Manage LSP in opencode.json",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Failed to create worktree",
|
||||
|
||||
@@ -221,6 +221,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const rich = (key: Parameters<typeof t>[0], params: Record<string, JSX.Element>) =>
|
||||
t(key)
|
||||
.split(/(\{\{\w+\}\})/g)
|
||||
.map((part, index) => (index % 2 ? (params[part.slice(2, -2)] ?? part) : part))
|
||||
|
||||
const pluralForm = (
|
||||
key: PluralKey,
|
||||
category: UiPluralCategory,
|
||||
@@ -262,6 +267,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
locales: LOCALES,
|
||||
label,
|
||||
t,
|
||||
rich,
|
||||
plural,
|
||||
pluralForm,
|
||||
setLocale(next: Locale) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Safari before 17.4 does not provide these runtime APIs.
|
||||
import "core-js/es/map/group-by"
|
||||
import "core-js/es/promise/with-resolvers"
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotFoundError } from "@opencode/client/promise"
|
||||
import type { FileNotFoundError, SessionNotFoundError } from "@opencode/client/promise"
|
||||
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors"
|
||||
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors"
|
||||
|
||||
@@ -87,6 +87,16 @@ describe("formatServerError", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("returns typed server error messages", () => {
|
||||
const error = {
|
||||
_tag: "FileNotFoundError",
|
||||
path: "deleted.txt",
|
||||
message: "File not found: deleted.txt",
|
||||
} satisfies FileNotFoundError
|
||||
|
||||
expect(formatServerError(error, language.t)).toBe("File not found: deleted.txt")
|
||||
})
|
||||
|
||||
test("returns provided string errors", () => {
|
||||
expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server")
|
||||
})
|
||||
|
||||
@@ -29,6 +29,14 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
|
||||
const unwrapped = unwrapNamedError(error)
|
||||
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
|
||||
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
|
||||
if (
|
||||
typeof unwrapped === "object" &&
|
||||
unwrapped !== null &&
|
||||
"message" in unwrapped &&
|
||||
typeof unwrapped.message === "string" &&
|
||||
unwrapped.message
|
||||
)
|
||||
return unwrapped.message
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
if (fallback) return fallback
|
||||
|
||||
@@ -102,7 +102,14 @@ describe("readLocalImage", () => {
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
|
||||
})
|
||||
|
||||
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
|
||||
test("propagates missing-file errors", async () => {
|
||||
const error = { _tag: "FileNotFoundError", path: "image.png", message: "File not found: image.png" }
|
||||
const { api } = setup(() => Response.json(error, { status: 404 }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
|
||||
})
|
||||
|
||||
test("does not turn an unexpected HTTP status into a Blob", async () => {
|
||||
const status = 500
|
||||
const { api } = setup(() => new Response("Not an image", { status }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -28,25 +27,9 @@ 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 notificationCoordinator = createNotificationCoordinator()
|
||||
|
||||
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>()
|
||||
|
||||
@@ -86,17 +69,6 @@ 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)
|
||||
|
||||
@@ -25,11 +25,15 @@ 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: () => dialog.close(),
|
||||
onSelect: (server) => {
|
||||
props.onSave?.(server)
|
||||
dialog.close()
|
||||
},
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
@@ -133,7 +137,7 @@ export const DialogServer: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
function createFormController(options: { onSelect?: (server: ServerConnection.Http) => void } = {}) {
|
||||
const platform = usePlatform()
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
@@ -220,13 +224,14 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
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?.()
|
||||
options.onSelect?.(connection)
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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,10 +1,8 @@
|
||||
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"
|
||||
@@ -49,6 +47,27 @@ 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()
|
||||
@@ -78,8 +97,8 @@ export function useServerActionsController() {
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
|
||||
},
|
||||
isHidden: server.isHidden,
|
||||
setHidden: server.setHidden,
|
||||
isHidden: (key: ServerConnection.Key) => server.isHidden(key),
|
||||
setHidden: (key: ServerConnection.Key, hidden: boolean) => server.setHidden(key, hidden),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -89,27 +108,16 @@ 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(() => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
const sorted = createMemo(() =>
|
||||
sortServerConnections({
|
||||
servers: items(),
|
||||
health: global.servers.health,
|
||||
defaultKey: actions.defaults.key(),
|
||||
}),
|
||||
)
|
||||
|
||||
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"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
remove: language.t("dialog.server.menu.remove"),
|
||||
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.delete}</Menu.Item>
|
||||
<Menu.Item onSelect={props.onRemove}>{props.labels.remove}</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
|
||||
@@ -11,14 +11,16 @@ import { Spinner } from "@opencode/ui/spinner"
|
||||
import { sshName } from "./name"
|
||||
import { isSshConnecting } from "./status"
|
||||
|
||||
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
|
||||
export function SshServerSettings(props: { filter: string; id?: string; domain: ServerCollectionController }) {
|
||||
const ssh = useSsh()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<For
|
||||
each={ssh.servers.filter(
|
||||
(item) =>
|
||||
item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
|
||||
item.saved &&
|
||||
(!props.id || item.config.id === props.id) &&
|
||||
`${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
|
||||
)}
|
||||
>
|
||||
{(item) => {
|
||||
|
||||
@@ -16,13 +16,14 @@ 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 }) {
|
||||
export function AddServerMenu(props: { onAddServer: () => void; compact?: boolean }) {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
@@ -33,15 +34,41 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
<Show
|
||||
when={platform.wslServers || platform.sshServers}
|
||||
fallback={
|
||||
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<Menu gutter={4} modal={false} placement="bottom-end">
|
||||
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Menu.Trigger>
|
||||
<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.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
|
||||
@@ -72,7 +99,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
servers: Accessor<readonly WslServerItem[]>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
@@ -160,7 +187,9 @@ export function WslServerSettings(props: {
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
|
||||
<Menu.Item disabled={request.isPending} onSelect={() => remove(key)}>
|
||||
{language.t("dialog.server.menu.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
|
||||
@@ -458,6 +458,11 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => (
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</ScrollView>
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { JSX } from "solid-js"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
|
||||
export function FileVisual(props: {
|
||||
path: string
|
||||
active?: boolean
|
||||
temporary?: boolean
|
||||
notFound?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||
<Show
|
||||
@@ -15,7 +20,11 @@ export function FileVisual(props: { path: string; active?: boolean; temporary?:
|
||||
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
|
||||
<span
|
||||
class="text-14-medium truncate"
|
||||
classList={{ italic: props.temporary, "line-through": props.notFound }}
|
||||
data-file-not-found={props.notFound ? "" : undefined}
|
||||
>
|
||||
{getFilename(props.path)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
@@ -33,11 +34,15 @@ export function SortableTab(props: {
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const notFound = createMemo(() => {
|
||||
const value = path()
|
||||
return value ? file.notFound(value) : false
|
||||
})
|
||||
const custom = children(() => props.children)
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
return <FileVisual path={value} temporary={props.temporary} />
|
||||
return <FileVisual path={value} temporary={props.temporary} notFound={notFound()} />
|
||||
})
|
||||
return (
|
||||
<div ref={sortable.ref} class="h-full flex items-center">
|
||||
@@ -46,6 +51,9 @@ export function SortableTab(props: {
|
||||
value={props.tab}
|
||||
id={props.id}
|
||||
aria-controls={props.ariaControls}
|
||||
aria-label={
|
||||
notFound() && path() ? language.t("file.error.notFound", { name: getFilename(path()!) }) : undefined
|
||||
}
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
closeButton={
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
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 }) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
import { Show } from "solid-js"
|
||||
|
||||
export function SessionHeaderSpacer(props: { visible: boolean }) {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarRight>
|
||||
<Show when={isDesktop() && settings.visibility.status()}>
|
||||
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<Show when={isDesktop() && props.reserveReviewToggle}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
<Show when={isDesktop() && props.visible}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -396,6 +396,7 @@ export function createSessionReview(input: {
|
||||
deferRender: input.deferRender,
|
||||
details: {
|
||||
diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
|
||||
open: () => state.detailsOpen,
|
||||
setOpen: (open: boolean) => setState("detailsOpen", open),
|
||||
},
|
||||
diffVersion: () => vcsQuery.dataUpdatedAt,
|
||||
|
||||
@@ -15,11 +15,6 @@ import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
return { default: StatusDrawer }
|
||||
})
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
return { default: MobilePanelDrawer }
|
||||
@@ -34,11 +29,9 @@ export function SessionMobileViewTabs(props: {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
menu: false,
|
||||
status: false,
|
||||
statusLoaded: false,
|
||||
details: false,
|
||||
detailsLoaded: false,
|
||||
pending: undefined as "status" | "details" | undefined,
|
||||
pending: false,
|
||||
})
|
||||
createEffect(() => props.onDetailsOpenChange?.(store.details))
|
||||
onCleanup(() => props.onDetailsOpenChange?.(false))
|
||||
@@ -95,32 +88,18 @@ export function SessionMobileViewTabs(props: {
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!store.pending) return
|
||||
event.preventDefault()
|
||||
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
|
||||
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
|
||||
setStore("pending", undefined)
|
||||
setStore({ details: true, detailsLoaded: true, pending: false })
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
|
||||
<Show when={props.details}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: true, menu: false })}>
|
||||
{language.t("session.summary.title")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
|
||||
{language.t("status.popover.trigger")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.statusLoaded}>
|
||||
<Suspense>
|
||||
<StatusDrawer
|
||||
open={store.status}
|
||||
onOpenChange={(open) => setStore("status", open)}
|
||||
returnFocus={() => trigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={store.detailsLoaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
createEffect,
|
||||
createComputed,
|
||||
on,
|
||||
onMount,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
@@ -41,6 +44,11 @@ const SessionMobileFiles = lazy(async () => {
|
||||
return { default: SessionMobileFiles }
|
||||
})
|
||||
|
||||
const SessionSummaryPanel = lazy(async () => {
|
||||
const { SessionSummaryPanel } = await import("./summary/panel")
|
||||
return { default: SessionSummaryPanel }
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
const server = useServer()
|
||||
@@ -70,11 +78,24 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
sideTerminalPresent: false,
|
||||
mobileTerminalCached: false,
|
||||
mobileMoveDismissed: false,
|
||||
summaryResizeTranslate: undefined as string | undefined,
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
chat?: HTMLDivElement
|
||||
side?: HTMLDivElement
|
||||
bottomTerminal?: HTMLDivElement
|
||||
}>({})
|
||||
const finishWindowResize = debounce(() => setStore("summaryResizeTranslate", undefined), 150)
|
||||
onMount(() => {
|
||||
makeEventListener(window, "resize", () => {
|
||||
if (store.summaryResizeTranslate === undefined) {
|
||||
const content = elements.chat?.querySelector("[data-timeline-virtual-content]")
|
||||
// Freeze the painted offset, including an in-flight slide, until resizing settles.
|
||||
setStore("summaryResizeTranslate", content ? getComputedStyle(content).translate : "none")
|
||||
}
|
||||
finishWindowResize()
|
||||
})
|
||||
})
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
|
||||
@@ -333,6 +354,9 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
ref={(element) => setElements("chat", element)}
|
||||
data-summary-open={isDesktop() && review.details.open()}
|
||||
data-summary-resizing={store.summaryResizeTranslate !== undefined}
|
||||
data-width-animating={store.sideWidthMotion}
|
||||
data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion}
|
||||
onPointerMove={revealTimelineScrollbar}
|
||||
@@ -344,6 +368,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
onTransitionCancel={trackSideWidthMotion}
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
"--session-summary-resize-translate": store.summaryResizeTranslate,
|
||||
}}
|
||||
>
|
||||
<Show when={!!session.identity.params.id}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -10,12 +9,14 @@ 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"
|
||||
@@ -42,9 +43,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,
|
||||
@@ -64,11 +65,13 @@ export function SessionProjectMenu(props: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const openProjectSettings = async () => {
|
||||
const openProjectSettings = () => {
|
||||
const current = props.project
|
||||
if (!current) return
|
||||
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
|
||||
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
|
||||
settingsSurface.openProject({
|
||||
server: ServerConnection.key(server.conn),
|
||||
project: current.worktree,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -191,7 +194,7 @@ export function SessionProjectMenu(props: {
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
|
||||
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
|
||||
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
|
||||
{language.t("project.settings.title")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { useData } from "@opencode/session-ui/context"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export type BackgroundTask = {
|
||||
id: string
|
||||
type: "shell" | "subagent"
|
||||
label: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [store, setStore] = createStore({ open: false })
|
||||
createEffect(() => {
|
||||
if (props.tasks.length > 0) return
|
||||
setStore("open", false)
|
||||
})
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
if (task.type === "shell") return language.t("ui.tool.shell")
|
||||
if (!task.agent) return language.t("ui.tool.agent.default")
|
||||
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={store.open}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={(open) => setStore("open", open)}
|
||||
>
|
||||
<Show when={props.tasks.length > 0}>
|
||||
<Popover.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="session-summary-row"
|
||||
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
|
||||
>
|
||||
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.plural("session.background.tasksRunning", props.tasks.length)}
|
||||
active
|
||||
class="session-summary-label"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</Show>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="session-service-menu"
|
||||
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
|
||||
>
|
||||
<For each={props.tasks.slice(0, 10)}>
|
||||
{(task) => (
|
||||
<Dynamic
|
||||
component={task.type === "subagent" ? "a" : "div"}
|
||||
data-component="session-background-list-item"
|
||||
class="session-service-row"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
|
||||
task.type === "subagent",
|
||||
}}
|
||||
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (task.type !== "subagent" || !data.navigateToSession) return
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
setStore("open", false)
|
||||
data.navigateToSession(task.id)
|
||||
}}
|
||||
>
|
||||
<span class="shrink-0">{taskType(task)}</span>
|
||||
<span class="session-summary-label text-v2-text-text-faint">{task.label}</span>
|
||||
</Dynamic>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { configuredLsps } from "./configured-lsp"
|
||||
|
||||
test("lists configured LSP names with project overrides and no inferred built-ins", () => {
|
||||
expect(
|
||||
configuredLsps([
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
rust: { command: ["rust-analyzer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { disabled: true },
|
||||
eslint: { command: ["vscode-eslint-language-server", "--stdio"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual(["eslint", "rust"])
|
||||
expect(configuredLsps([{ type: "document", info: { lsp: true } }])).toEqual([])
|
||||
})
|
||||
|
||||
test("a later whole-LSP setting clears earlier names", () => {
|
||||
expect(
|
||||
configuredLsps([
|
||||
{ type: "document", info: { lsp: { rust: { command: ["rust-analyzer"] } } } },
|
||||
{ type: "document", info: { lsp: false } },
|
||||
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
|
||||
]),
|
||||
).toEqual(["custom"])
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ConfigEntry } from "@opencode/client"
|
||||
|
||||
export function configuredLsps(entries: readonly ConfigEntry[]) {
|
||||
return entries
|
||||
.reduce<string[]>((names, entry) => {
|
||||
if (entry.type !== "document" || entry.info.lsp === undefined) return names
|
||||
const lsp = entry.info.lsp
|
||||
if (typeof lsp === "boolean") return []
|
||||
return [
|
||||
...names.filter((name) => !Object.hasOwn(lsp, name)),
|
||||
...Object.entries(lsp)
|
||||
.filter(([, server]) => !server.disabled)
|
||||
.map(([name]) => name),
|
||||
]
|
||||
}, [])
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
|
||||
import { BackgroundWorkSummary, type BackgroundTask } from "./background"
|
||||
import { SessionServerPanel } from "./server-panel"
|
||||
import { ProjectSummaryCard } from "./project-card"
|
||||
import "./summary.css"
|
||||
|
||||
export function SessionSummaryPanel(props: {
|
||||
shown?: boolean
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs?: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
backgroundTasks: BackgroundTask[]
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const expanded = settings.sessionSummary.projectExpanded
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
const location = () => {
|
||||
if (props.local) return language.t("session.new.workspace.local")
|
||||
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
|
||||
return getFilename(workspace ?? props.directory)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
|
||||
<div>
|
||||
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
class="session-summary-row"
|
||||
>
|
||||
<Icon name={props.local ? "monitor" : "outline-worktree"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{props.branch}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="session-summary-row" onClick={props.onReview}>
|
||||
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label flex items-center gap-2">
|
||||
<Show
|
||||
when={props.diffs}
|
||||
fallback={
|
||||
<span class="truncate text-v2-text-text-muted">{language.t("session.review.loadingChanges")}</span>
|
||||
}
|
||||
>
|
||||
{(diffs) => (
|
||||
<Show
|
||||
when={diffs().length > 0}
|
||||
fallback={
|
||||
<span class="truncate text-v2-text-text-muted">{language.t("session.review.noChanges")}</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">
|
||||
{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}
|
||||
</span>
|
||||
<span class="shrink-0 text-v2-text-text-muted">·</span>
|
||||
<DiffChanges appearance="standard" changes={diffs()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</ProjectSummaryCard>
|
||||
<Show when={expanded() && props.local && props.diffs?.length && props.moveEligible && !props.moveDismissed}>
|
||||
<div class="session-summary-move">
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
class="session-summary-row"
|
||||
>
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class="session-summary-dismiss"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={props.onMoveDismiss}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<SessionServerPanel directory={props.directory} shown={props.shown !== false} mobile={props.mobile} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Show, type ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import "./summary.css"
|
||||
|
||||
export function SummaryPopover(
|
||||
props: ParentProps<{ active?: boolean; open: boolean; onOpenChange: (open: boolean) => void }>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
// Cached timelines remain mounted; only the visible summary owns the command.
|
||||
command.register(() =>
|
||||
props.active === false
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "session.summary.toggle",
|
||||
title: language.t("command.session.summary.toggle"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+shift+y",
|
||||
onSelect: () => props.onOpenChange(!props.open),
|
||||
},
|
||||
],
|
||||
)
|
||||
const keybind = () => command.keybindParts("session.summary.toggle")
|
||||
return (
|
||||
<Popover open={props.open} placement="bottom-end" gutter={8} overflowPadding={16} onOpenChange={props.onOpenChange}>
|
||||
{/* Match the button's vertical bounds; the 8px gutter plus 4px content padding gives a 12px card gap. */}
|
||||
<Popover.Anchor class="pointer-events-none absolute end-3 top-2.5 h-7 w-0" aria-hidden="true" />
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("session.summary.tooltip")}
|
||||
<Show when={keybind().length > 0}>
|
||||
<Keybind keys={keybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={props.open ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={props.open}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="session-summary-popover z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
|
||||
aria-label={language.t("session.summary.title")}
|
||||
>
|
||||
{props.children}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import "./summary.css"
|
||||
|
||||
export function ProjectSummaryCard(
|
||||
props: ParentProps<{
|
||||
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
|
||||
avatar?: JSX.Element
|
||||
}>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
const contentID = createUniqueId()
|
||||
const expanded = settings.sessionSummary.projectExpanded
|
||||
return (
|
||||
<section class="session-summary-card" data-section="project">
|
||||
<button
|
||||
type="button"
|
||||
class="session-summary-row session-summary-heading"
|
||||
aria-label={displayName(props.project)}
|
||||
aria-expanded={expanded()}
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
|
||||
>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="session-summary-disclosure" />
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { getDirectory } from "@opencode/util/path"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Index,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { pluginLabel } from "@/providers/catalog/plugin"
|
||||
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
|
||||
import { configuredLsps } from "./configured-lsp"
|
||||
|
||||
const services = [
|
||||
{ type: "mcp", icon: "mcp", label: "session.summary.mcp" },
|
||||
{ type: "plugins", icon: "cube", label: "session.summary.plugins" },
|
||||
{ type: "skills", icon: "graduation-cap", label: "session.summary.skills" },
|
||||
{ type: "lsp", icon: "code-slash", label: "session.summary.lsp" },
|
||||
] as const
|
||||
|
||||
type Service = (typeof services)[number]["type"]
|
||||
|
||||
type ServiceMenuProps = {
|
||||
service: (typeof services)[number]
|
||||
directory: string
|
||||
shown: boolean
|
||||
open: boolean
|
||||
mobile?: boolean
|
||||
mcp?: McpControls
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const contentID = createUniqueId()
|
||||
const expanded = settings.sessionSummary.serverExpanded
|
||||
const name = createMemo(() => {
|
||||
const servers = global.servers.list()
|
||||
if (servers.length < 2) return language.t("session.summary.server")
|
||||
return serverName(servers.find((connection) => ServerConnection.key(connection) === server.key) ?? server.conn)
|
||||
})
|
||||
const [store, setStore] = createStore<{ submenu?: Service }>({})
|
||||
createEffect(on([() => props.directory, () => props.shown, expanded], () => setStore("submenu", undefined)))
|
||||
|
||||
return (
|
||||
<section class="session-summary-card" data-section="server">
|
||||
<button
|
||||
type="button"
|
||||
class="session-summary-row session-summary-heading"
|
||||
aria-expanded={expanded()}
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
|
||||
>
|
||||
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{name()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="session-summary-disclosure" />
|
||||
</button>
|
||||
<Show when={expanded() ? props.directory : undefined} keyed>
|
||||
{(directory) => (
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
<For each={services}>
|
||||
{(service) => (
|
||||
<ServiceMenu
|
||||
service={service}
|
||||
directory={directory}
|
||||
shown={props.shown}
|
||||
open={store.submenu === service.type}
|
||||
mobile={props.mobile}
|
||||
mcp={props.mcp}
|
||||
onOpenChange={(open) => setStore("submenu", open ? service.type : undefined)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceMenu(props: ServiceMenuProps) {
|
||||
if (props.service.type === "mcp") return <McpMenu {...props} />
|
||||
if (props.service.type === "lsp") return <LspMenu {...props} />
|
||||
return <ServiceCatalog {...props} />
|
||||
}
|
||||
|
||||
function LspMenu(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
(directory) => {
|
||||
data.location.config.invalidate({ directory })
|
||||
return data.location.config.sync({ directory })
|
||||
},
|
||||
)
|
||||
const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? []))
|
||||
createEffect(() => {
|
||||
onCleanup(sdk.event.location(props.directory).on("config.updated", () => void refetch()))
|
||||
})
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={data.location.config.list({ directory: props.directory }) !== undefined}
|
||||
empty={names().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={names().length}
|
||||
fallback={
|
||||
<ServiceEmpty title={language.t("session.summary.lsp.empty")} directory={props.directory} service="lsp" />
|
||||
}
|
||||
>
|
||||
<h3 class="session-service-title">{language.t("session.summary.lsp.configured")}</h3>
|
||||
<For each={names()}>
|
||||
{(name) => (
|
||||
<div class="session-service-row">
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service="lsp" />
|
||||
</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function McpMenu(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const toggle = useMcpToggle(() => props.directory)
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && ([props.directory, props.mcp?.preview] as const),
|
||||
async ([directory, preview]) => {
|
||||
data.location.mcp.server.invalidate({ directory })
|
||||
await Promise.all([
|
||||
data.location.mcp.server.sync({ directory }),
|
||||
...(preview ? [data.location.config.sync({ directory })] : []),
|
||||
])
|
||||
},
|
||||
)
|
||||
const servers = createMemo(() =>
|
||||
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
const defaults = createMemo(() =>
|
||||
Object.fromEntries(
|
||||
(data.location.config.list({ directory: props.directory }) ?? []).flatMap((entry) =>
|
||||
entry.type === "document"
|
||||
? Object.entries(entry.info.mcp?.servers ?? {}).map(([name, config]) => [name, !config.disabled] as const)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={
|
||||
data.location.mcp.server.list({ directory: props.directory }) !== undefined &&
|
||||
(!props.mcp?.preview || data.location.config.list({ directory: props.directory }) !== undefined)
|
||||
}
|
||||
empty={servers().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={servers().length}
|
||||
fallback={
|
||||
<ServiceEmpty title={language.t("session.summary.mcp.empty")} directory={props.directory} service="mcp" />
|
||||
}
|
||||
>
|
||||
<h3 class="session-service-title">{language.t("session.summary.mcp.title")}</h3>
|
||||
<Show when={props.mcp?.preview}>
|
||||
<div class="session-service-message" data-slot="mcp-preview-hint">
|
||||
{language.t("session.summary.mcp.onCreation")}
|
||||
</div>
|
||||
</Show>
|
||||
<Index each={servers()}>
|
||||
{(server) => {
|
||||
const preview = () => props.mcp?.preview === true
|
||||
const enabled = () =>
|
||||
preview()
|
||||
? (props.mcp?.states[server().name] ?? defaults()[server().name] ?? true)
|
||||
: server().status.status !== "disabled"
|
||||
const pending = () =>
|
||||
(props.mcp?.pending ?? toggle.isPending) || (!preview() && server().status.status === "pending")
|
||||
const error = () => {
|
||||
const status = server().status
|
||||
return status.status === "failed" ? status.error : undefined
|
||||
}
|
||||
const label = () => {
|
||||
if (preview()) return undefined
|
||||
const status = server().status.status
|
||||
if (status === "failed") return language.t("session.summary.failed")
|
||||
if (status === "pending") return language.t("session.summary.connecting")
|
||||
if (status === "needs_auth") return language.t("session.summary.needsAuth")
|
||||
return undefined
|
||||
}
|
||||
const change = (value: boolean) => {
|
||||
if (pending()) return
|
||||
if (props.mcp) return props.mcp.change(server().name, value)
|
||||
toggle.mutate({ name: server().name, enabled: value })
|
||||
}
|
||||
return (
|
||||
<Switch
|
||||
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
|
||||
description={preview() ? language.t("session.summary.mcp.onCreation") : label()}
|
||||
checked={enabled()}
|
||||
readOnly={pending()}
|
||||
aria-disabled={pending()}
|
||||
aria-busy={props.mcp?.pending ?? toggle.isPending}
|
||||
onChange={change}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (event.target === event.currentTarget) change(!enabled())
|
||||
}}
|
||||
title={preview() ? server().name : (error() ?? server().name)}
|
||||
>
|
||||
<span
|
||||
class="session-service-dot"
|
||||
data-status={preview() ? undefined : server().status.status}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{server().name}
|
||||
</span>
|
||||
<Show when={label()}>
|
||||
{(status) => (
|
||||
<span class="session-service-status" aria-hidden="true">
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service="mcp" />
|
||||
</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceCatalog(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [items, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
async (directory) => {
|
||||
if (props.service.type === "plugins") {
|
||||
const result = await sdk.api.plugin.list({ location: { directory } })
|
||||
return result.data
|
||||
.filter((plugin) => plugin.source.type !== "builtin")
|
||||
.map((plugin) => ({
|
||||
name: pluginLabel(plugin),
|
||||
status: plugin.state.status,
|
||||
error: plugin.state.status === "failed" ? plugin.state.error : undefined,
|
||||
}))
|
||||
}
|
||||
data.location.skill.invalidate({ directory })
|
||||
await data.location.skill.sync({ directory })
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
const loaded = () => items.state === "ready" || items.state === "refreshing"
|
||||
const list = createMemo(() => {
|
||||
const entries =
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
? (items.latest ?? [])
|
||||
: []
|
||||
: (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
|
||||
name: skill.name,
|
||||
status: "active",
|
||||
error: undefined,
|
||||
}))
|
||||
return entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
createEffect(() => {
|
||||
onCleanup(
|
||||
sdk.event
|
||||
.location(props.directory)
|
||||
.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", () => void refetch()),
|
||||
)
|
||||
})
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={items.loading}
|
||||
ready={
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
: data.location.skill.list({ directory: props.directory }) !== undefined
|
||||
}
|
||||
empty={list().length === 0}
|
||||
error={items.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={list().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
|
||||
)}
|
||||
directory={props.directory}
|
||||
service={props.service.type}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h3 class="session-service-title">
|
||||
{language.t(
|
||||
props.service.type === "plugins"
|
||||
? "session.summary.plugins.configured"
|
||||
: "session.summary.skills.configured",
|
||||
)}
|
||||
</h3>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<div class="session-service-row" title={item.error ?? item.name}>
|
||||
<span class="session-service-dot" data-status={item.status} aria-hidden="true" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{item.name}
|
||||
</span>
|
||||
<Show when={item.status === "failed"}>
|
||||
<span class="session-service-status">{language.t("session.summary.failed")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service={props.service.type} />
|
||||
</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServicePopover(
|
||||
props: ServiceMenuProps & {
|
||||
loading: boolean
|
||||
ready: boolean
|
||||
empty: boolean
|
||||
error: unknown
|
||||
retry: () => unknown
|
||||
children: JSX.Element
|
||||
},
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
return (
|
||||
<Popover
|
||||
open={props.open}
|
||||
onOpenChange={(open) => {
|
||||
props.onOpenChange(open)
|
||||
if (open && !props.loading) void props.retry()
|
||||
}}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={16}
|
||||
modal={false}
|
||||
>
|
||||
<Popover.Trigger as="button" type="button" class="session-summary-row">
|
||||
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t(props.service.label)}</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="session-service-menu"
|
||||
data-service={props.service.type}
|
||||
data-empty={(props.ready && !props.error && props.empty) || undefined}
|
||||
aria-busy={props.loading}
|
||||
aria-label={language.t(props.service.label)}
|
||||
>
|
||||
<Show
|
||||
when={props.ready || !props.loading}
|
||||
fallback={
|
||||
<div class="session-service-message" role="status">
|
||||
{language.t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!props.error}
|
||||
fallback={
|
||||
<div class="session-service-message" role="alert">
|
||||
<p>{language.t("common.requestFailed")}</p>
|
||||
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
|
||||
{language.t("session.summary.retry")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceConfigLink(props: { directory: string; service: Service }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const [store, setStore] = createStore({ opening: false, copied: false })
|
||||
const label = () => language.t(server.isLocal ? "session.summary.configure" : "session.summary.copyConfigPath")
|
||||
createEffect(() => {
|
||||
if (!store.copied) return
|
||||
const timeout = setTimeout(() => setStore("copied", false), 2000)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
const activate = async () => {
|
||||
const revealPath = platform.revealPath
|
||||
if (store.opening || (server.isLocal && !revealPath)) return
|
||||
setStore({ opening: true, copied: false })
|
||||
const directory = props.directory
|
||||
await sdk.api.config
|
||||
.get({ location: { directory } })
|
||||
.then(async (entries) => {
|
||||
const documents = entries
|
||||
.filter((entry) => entry.type === "document")
|
||||
.filter((entry) => entry.path !== undefined && /\.jsonc?$/.test(entry.path))
|
||||
const path =
|
||||
documents.findLast((entry) => entry.info[props.service] !== undefined)?.path ?? documents.at(-1)?.path
|
||||
if (!server.isLocal) {
|
||||
if (!path) throw new Error(language.t("session.summary.configFileMissing"))
|
||||
await (platform.writeClipboardText?.(path) ?? navigator.clipboard.writeText(path))
|
||||
setStore("copied", true)
|
||||
return
|
||||
}
|
||||
if (path && (await revealPath?.(path))) return
|
||||
await platform.openPath?.(path ? getDirectory(path) : directory)
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
)
|
||||
.finally(() => setStore("opening", false))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span class="session-service-config-separator" role="separator" />
|
||||
<Show
|
||||
when={!server.isLocal || platform.revealPath}
|
||||
fallback={
|
||||
<span class="session-service-row">
|
||||
<Icon name="settings-gear" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
{label()}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
inactive={server.isLocal}
|
||||
value={language.t(store.copied ? "ui.message.copied" : "ui.message.copy")}
|
||||
placement="top"
|
||||
getAnchorRect={(anchor) => anchor?.querySelector("svg")?.getBoundingClientRect()}
|
||||
forceOpen={store.copied ? true : undefined}
|
||||
class="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="session-service-config"
|
||||
disabled={store.opening}
|
||||
onMouseDown={(event) => {
|
||||
if (!server.isLocal) event.preventDefault()
|
||||
}}
|
||||
onClick={() => void activate()}
|
||||
>
|
||||
<Icon
|
||||
name={server.isLocal ? "settings-gear" : store.copied ? "check" : "outline-copy"}
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="session-summary-label">{label()}</span>
|
||||
<Show when={server.isLocal}>
|
||||
<Icon name="arrow-up-right" class="session-service-config-arrow shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceEmpty(props: { title: string; directory: string; service: Service }) {
|
||||
return (
|
||||
<div class="session-service-empty">
|
||||
<strong>{props.title}</strong>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service={props.service} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
.session-summary-popover {
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
}
|
||||
|
||||
[data-component="session-summary-panel"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 32px);
|
||||
|
||||
&[data-mobile] {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.session-summary-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] .session-summary-card {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
.session-summary-card,
|
||||
.session-summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.session-summary-row,
|
||||
.session-service-row,
|
||||
.session-service-config,
|
||||
[data-component="switch"].session-mcp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.session-summary-card .session-summary-row {
|
||||
/* Keep copy out of the trailing indicator column, including rows without an indicator. */
|
||||
padding-inline-end: 36px;
|
||||
}
|
||||
|
||||
.session-summary-card .session-summary-row:has(> .session-summary-menu-indicator) {
|
||||
/* Align trailing 16px indicators with the move row's 20px dismiss button. */
|
||||
padding-inline-end: 8px;
|
||||
}
|
||||
|
||||
.session-summary-card .session-summary-menu-indicator {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.session-summary-row:focus-visible,
|
||||
.session-service-config:focus-visible,
|
||||
.session-mcp-row:focus-within {
|
||||
outline: none;
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button.session-summary-row:hover,
|
||||
.session-service-config:not(:disabled):hover,
|
||||
.session-summary-move:has(> .session-summary-dismiss:hover) > .session-summary-row,
|
||||
.session-mcp-row:not([data-disabled]):hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.session-summary-row[data-expanded] {
|
||||
background: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
.session-summary-heading {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.session-summary-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
}
|
||||
.session-summary-heading .session-summary-label {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.session-summary-disclosure {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
.session-summary-heading[aria-expanded="false"] .session-summary-disclosure {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.session-summary-heading[aria-expanded="false"]:dir(rtl) .session-summary-disclosure {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.session-service-menu {
|
||||
z-index: 60;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: min(480px, calc(100dvh - 32px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
outline: none;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
|
||||
&[data-empty] {
|
||||
width: 232px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-menu .session-summary-row,
|
||||
.session-service-menu .session-service-row,
|
||||
.session-service-menu .session-service-config,
|
||||
.session-service-menu [data-component="switch"].session-mcp-row {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.session-service-menu > :is(.session-service-row, .session-mcp-row) + :is(.session-service-row, .session-mcp-row) {
|
||||
margin-block-start: 2px;
|
||||
}
|
||||
|
||||
.session-service-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: inherit;
|
||||
font-weight: 530;
|
||||
letter-spacing: 0.05px;
|
||||
}
|
||||
|
||||
.session-service-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: inherit;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
strong {
|
||||
padding: 8px 12px;
|
||||
font-weight: 530;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-message {
|
||||
padding: 6px 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.session-service-menu > .session-service-footer {
|
||||
padding-block-start: 6px;
|
||||
}
|
||||
.session-service-config-separator {
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: calc(100% + 4px);
|
||||
margin: 2px -2px;
|
||||
background: var(--v2-border-border-muted);
|
||||
}
|
||||
.session-service-config {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--v2-text-text-base);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
.session-service-config-arrow {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) .session-service-config-arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
.session-service-dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-faint);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
.session-service-dot[data-status="connected"],
|
||||
.session-service-dot[data-status="active"] {
|
||||
color: var(--icon-success-base);
|
||||
}
|
||||
.session-service-dot[data-status="failed"] {
|
||||
color: var(--icon-critical-base);
|
||||
}
|
||||
.session-service-dot[data-status="needs_auth"] {
|
||||
color: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
.session-service-status {
|
||||
flex-shrink: 0;
|
||||
font-size: inherit;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
.session-mcp-row[aria-disabled="true"] [data-slot="switch-control"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.session-summary-move {
|
||||
position: relative;
|
||||
/* The first 6px sit behind the card, leaving 4px of visible space above the row. */
|
||||
padding: 10px 2px 2px;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
.session-summary-move > .session-summary-row {
|
||||
padding-inline-end: 36px;
|
||||
}
|
||||
.session-summary-dismiss {
|
||||
position: absolute;
|
||||
inset-inline-end: 8px;
|
||||
bottom: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.session-summary-dismiss:hover,
|
||||
.session-summary-dismiss:focus-visible {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-component="session-summary-panel"] .session-summary-row,
|
||||
.session-service-menu .session-service-title,
|
||||
.session-service-menu .session-service-row,
|
||||
.session-service-menu .session-service-config,
|
||||
.session-service-menu .session-mcp-row {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-summary-popover,
|
||||
.session-service-menu {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.session-service-config .session-service-config-arrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DataProvider } from "@opencode/session-ui/context"
|
||||
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
|
||||
import { BackgroundMoveHint } from "./message-timeline"
|
||||
import { BackgroundWorkSummary } from "../summary/background"
|
||||
import "../summary/summary.css"
|
||||
|
||||
const tasks = [
|
||||
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
Suspense,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import type { SessionUserActions } from "@opencode/session-ui/actions"
|
||||
import { useData } from "@opencode/session-ui/context"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { InlineInput } from "@opencode/ui/inline-input"
|
||||
@@ -13,34 +21,30 @@ import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { SummaryPopover } from "../summary/popover"
|
||||
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
|
||||
import { Timeline } from "@opencode/session-ui/timeline/projection"
|
||||
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
|
||||
import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown-cache"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
import type { BackgroundTask } from "../summary/background"
|
||||
|
||||
type BackgroundTask = {
|
||||
id: string
|
||||
type: "shell" | "subagent"
|
||||
label: string
|
||||
agent?: string
|
||||
}
|
||||
const SessionSummaryPanel = lazy(async () => {
|
||||
const { SessionSummaryPanel } = await import("../summary/panel")
|
||||
return { default: SessionSummaryPanel }
|
||||
})
|
||||
|
||||
type SessionBackground = {
|
||||
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
|
||||
@@ -70,283 +74,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
|
||||
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
|
||||
const presence = createAnimatedPresence(
|
||||
() => (props.tasks.length > 0 ? true : undefined),
|
||||
() => triggerRef() ?? null,
|
||||
)
|
||||
createEffect(() => {
|
||||
if (props.tasks.length > 0) return
|
||||
setOpen(false)
|
||||
})
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
if (task.type === "shell") return language.t("ui.tool.shell")
|
||||
if (!task.agent) return language.t("ui.tool.agent.default")
|
||||
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
|
||||
>
|
||||
<Show when={presence.present()}>
|
||||
<Popover.Trigger
|
||||
ref={setTriggerRef}
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
}}
|
||||
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
>
|
||||
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
active
|
||||
class="min-w-0 flex-1 truncate text-start"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</Show>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
|
||||
>
|
||||
<For each={tasks().slice(0, 10)}>
|
||||
{(task) => (
|
||||
<Dynamic
|
||||
component={task.type === "subagent" ? "a" : "div"}
|
||||
data-component="session-background-list-item"
|
||||
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
|
||||
task.type === "subagent",
|
||||
}}
|
||||
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (task.type !== "subagent" || !data.navigateToSession) return
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
data.navigateToSession(task.id)
|
||||
}}
|
||||
>
|
||||
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
|
||||
</Dynamic>
|
||||
)}
|
||||
</For>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
mobile?: boolean
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const inline = () => props.variant === "inline"
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"group/workspace-move relative shrink-0": true,
|
||||
"ms-auto h-5 w-[167px]": inline(),
|
||||
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
|
||||
!inline(),
|
||||
hidden: props.dismissed,
|
||||
}}
|
||||
>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.eligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={
|
||||
props.mobile
|
||||
? "top-end"
|
||||
: inline()
|
||||
? "bottom-end"
|
||||
: language.direction() === "rtl"
|
||||
? "right-start"
|
||||
: "left-start"
|
||||
}
|
||||
gutter={props.mobile || inline() ? 4 : -22}
|
||||
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
|
||||
inline()
|
||||
? "end-0 top-1/2"
|
||||
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
|
||||
}`}
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDismiss()
|
||||
}}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionSummaryPanel(props: {
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs?: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
backgroundTasks: BackgroundTask[]
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => {
|
||||
if (props.local) return language.t("session.new.workspace.local")
|
||||
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
|
||||
return getFilename(workspace ?? props.directory)
|
||||
}
|
||||
const branch = () => props.branch ?? props.baseBranch
|
||||
const row =
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<Icon
|
||||
name={props.local ? "monitor" : "outline-worktree"}
|
||||
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||
onClick={props.onReview}
|
||||
>
|
||||
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
|
||||
{(diffs) => (
|
||||
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChanges appearance="standard" changes={diffs()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<div
|
||||
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
|
||||
classList={{
|
||||
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
|
||||
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
|
||||
}}
|
||||
>
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
mobile={props.mobile}
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
dismissed={props.moveDismissed}
|
||||
onDismiss={props.onMoveDismiss}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
hideHeader?: boolean
|
||||
active?: boolean
|
||||
@@ -819,42 +546,32 @@ function MessageTimelineView(
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
<SummaryPopover active={props.active} open={summaryOpen()} onOpenChange={setSummary}>
|
||||
<Suspense>
|
||||
<SessionSummaryPanel
|
||||
shown={summaryOpen()}
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Suspense>
|
||||
</SummaryPopover>
|
||||
)}
|
||||
</Show>
|
||||
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
|
||||
<SessionHeaderSpacer visible={props.reserveReviewToggle} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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?: string) {
|
||||
export function useSettingsDialog(defaultValue?: SettingsRootTab) {
|
||||
const settings = useSettingsSurface()
|
||||
return () => settings.open(defaultValue)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
@@ -14,32 +15,100 @@ import {
|
||||
useSettings,
|
||||
} from "@/settings/model"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
|
||||
import { createSoundPreviewController, type ShellOption } from "./behavior"
|
||||
import { createSoundPreviewController } 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 createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
|
||||
export function createServerPreferencesController(server: Accessor<ServerConnection.Any>) {
|
||||
const language = useLanguage()
|
||||
const serverCtx = useServerCtx(server)
|
||||
const [shells] = createResource(
|
||||
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 source = () => ServerConnection.key(server())
|
||||
const [preferences, preferencesActions] = createResource<ConfigPreferences, ServerConnection.Key>(
|
||||
source,
|
||||
() =>
|
||||
serverCtx()
|
||||
.sdk.api.config.preferences()
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
|
||||
const [shells] = createResource(
|
||||
source,
|
||||
() =>
|
||||
serverCtx()
|
||||
.sdk.api.config.shells()
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
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]
|
||||
})
|
||||
|
||||
return {
|
||||
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 })
|
||||
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 } })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -139,6 +208,6 @@ export function createSoundSettingsController() {
|
||||
}
|
||||
}
|
||||
|
||||
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||
export type ShellSettingsController = ReturnType<typeof createServerPreferencesController>["shell"]
|
||||
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||
|
||||
@@ -21,12 +21,10 @@ 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 = {
|
||||
@@ -85,6 +83,7 @@ const WorkspaceDestinationSetting: Component = () => {
|
||||
description={language.t("settings.workspaces.default.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-workspace-destination"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||
value={(option) => option.value}
|
||||
@@ -97,7 +96,7 @@ const WorkspaceDestinationSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
createShellOptions({
|
||||
@@ -295,15 +294,12 @@ const LanguageSetting = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsGeneral: Component<{
|
||||
server?: ServerConnection.Any
|
||||
}> = (props) => {
|
||||
export const SettingsGeneral: Component = () => {
|
||||
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(
|
||||
@@ -328,10 +324,32 @@ export const SettingsGeneral: Component<{
|
||||
<WorkspaceDestinationSetting />
|
||||
<AutoApprovePermissionsSetting />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<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>
|
||||
|
||||
<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")}
|
||||
@@ -365,50 +383,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdvancedSection = () => (
|
||||
<div class="settings-section">
|
||||
<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")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
@@ -474,7 +448,13 @@ export const SettingsGeneral: Component<{
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
|
||||
<Button
|
||||
data-action="settings-check-updates"
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
disabled={!updater.action().run}
|
||||
onClick={() => updater.run()}
|
||||
>
|
||||
{language.t(updater.action().label)}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
@@ -482,26 +462,6 @@ 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">
|
||||
@@ -514,7 +474,7 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<GeneralSection />
|
||||
|
||||
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
|
||||
@@ -532,10 +492,6 @@ export const SettingsGeneral: Component<{
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<AdvancedSection />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -342,7 +342,7 @@ export function createKeybindSettingsController(
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsKeybinds() {
|
||||
export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean }) {
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const controller = createKeybindSettingsController({
|
||||
@@ -352,6 +352,8 @@ export function SettingsKeybinds() {
|
||||
|
||||
return (
|
||||
<SettingsKeybindsView
|
||||
visible={props.active}
|
||||
autofocus={props.autofocus}
|
||||
groups={controller.catalog.groups}
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
@@ -365,6 +367,8 @@ export function SettingsKeybinds() {
|
||||
}
|
||||
|
||||
function SettingsKeybindsView(props: {
|
||||
visible?: boolean
|
||||
autofocus?: boolean
|
||||
groups: KeybindGroup[]
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
@@ -375,6 +379,20 @@ function SettingsKeybindsView(props: {
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let search: HTMLInputElement | undefined
|
||||
createEffect(
|
||||
on(
|
||||
() => props.visible ?? true,
|
||||
(visible) => {
|
||||
if (!visible) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.visible !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const filtered = createMemo(() => props.filtered(store.filter))
|
||||
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||
@@ -393,6 +411,7 @@ function SettingsKeybindsView(props: {
|
||||
</div>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
@@ -417,7 +436,7 @@ function SettingsKeybindsView(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-shortcuts flex flex-col gap-8">
|
||||
<div class="settings-shortcuts settings-section-stack">
|
||||
<For each={props.groups}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div data-component="settings-list">{props.children}</div>
|
||||
export const SettingsList: Component<{ children: JSX.Element; variant?: "catalog" }> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-list" data-variant={props.variant}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ describe("settings timeline detail migration", () => {
|
||||
})
|
||||
|
||||
describe("settings schema", () => {
|
||||
test("restores summary expansion and discards the retired status preference", () => {
|
||||
const settings = decode({
|
||||
general: { showStatus: true, showSearch: true },
|
||||
sessionSummary: { projectExpanded: false, serverExpanded: true },
|
||||
})
|
||||
expect(settings.general.showSearch).toBe(true)
|
||||
expect(settings.general).not.toHaveProperty("showStatus")
|
||||
expect(settings.sessionSummary).toEqual({ projectExpanded: false, serverExpanded: true })
|
||||
expect(decode(encode(settings)).sessionSummary).toEqual(settings.sessionSummary)
|
||||
})
|
||||
|
||||
test("uses the supplied initial values independently of the current schema", () => {
|
||||
const initial = {
|
||||
...defaultSettings,
|
||||
@@ -58,7 +69,6 @@ describe("settings schema", () => {
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
@@ -69,6 +79,7 @@ describe("settings schema", () => {
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
sessionSummary: { projectExpanded: true, serverExpanded: true },
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
mono: "",
|
||||
|
||||
@@ -79,7 +79,6 @@ const generalSchema = Persistence.struct({
|
||||
showFileTree: Schema.Boolean,
|
||||
showNavigation: Schema.Boolean,
|
||||
showSearch: Schema.Boolean,
|
||||
showStatus: Schema.Boolean,
|
||||
showProjectIcon: Schema.Boolean,
|
||||
showTerminal: Schema.Boolean,
|
||||
timelineDetail: Persistence.struct({
|
||||
@@ -135,6 +134,7 @@ const soundsSchema = Persistence.struct({
|
||||
|
||||
export const settingsSchema = Persistence.struct({
|
||||
general: generalSchema,
|
||||
sessionSummary: Persistence.struct({ projectExpanded: Schema.Boolean, serverExpanded: Schema.Boolean }),
|
||||
appearance: appearanceSchema,
|
||||
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
|
||||
permissions: permissionsSchema,
|
||||
@@ -243,7 +243,6 @@ export const defaultSettings: Settings = {
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
@@ -254,6 +253,7 @@ export const defaultSettings: Settings = {
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
sessionSummary: { projectExpanded: true, serverExpanded: true },
|
||||
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
|
||||
keybinds: {},
|
||||
permissions: { autoApprove: false },
|
||||
@@ -280,7 +280,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
const showCustomAgents = withFallback(
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
@@ -322,10 +321,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowSearch(value: boolean) {
|
||||
setStore("general", "showSearch", value)
|
||||
},
|
||||
showStatus,
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
|
||||
setShowProjectIcon(value: boolean) {
|
||||
setStore("general", "showProjectIcon", value)
|
||||
@@ -372,10 +367,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "experimentalBrowser", value)
|
||||
},
|
||||
},
|
||||
sessionSummary: {
|
||||
projectExpanded: withFallback(
|
||||
() => store.sessionSummary?.projectExpanded,
|
||||
defaultSettings.sessionSummary.projectExpanded,
|
||||
),
|
||||
serverExpanded: withFallback(
|
||||
() => store.sessionSummary?.serverExpanded,
|
||||
defaultSettings.sessionSummary.serverExpanded,
|
||||
),
|
||||
setProjectExpanded(value: boolean) {
|
||||
setStore("sessionSummary", "projectExpanded", value)
|
||||
},
|
||||
setServerExpanded(value: boolean) {
|
||||
setStore("sessionSummary", "serverExpanded", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
search: showSearch,
|
||||
status: showStatus,
|
||||
customAgents: showCustomAgents,
|
||||
},
|
||||
appearance: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Switch } from "@opencode/ui/switch"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -25,10 +24,24 @@ export const ModelProvidersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export const SettingsModels: Component = () => {
|
||||
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
let search: HTMLInputElement | undefined
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active ?? true,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.active !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
const [store, setStore] = persisted(
|
||||
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
|
||||
ModelProvidersSchema,
|
||||
@@ -65,10 +78,10 @@ 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
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={list.filter()}
|
||||
@@ -160,12 +173,12 @@ export const SettingsModels: Component = () => {
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-models-provider-icon shrink-0"
|
||||
/>
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList>
|
||||
<SettingsList variant="catalog">
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { SettingsSearch } from "./search"
|
||||
import "./search.css"
|
||||
|
||||
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 surface = useSettingsSurface()
|
||||
const searchable = () => surface.view().type === "root"
|
||||
const language = useLanguage()
|
||||
const back = () => {
|
||||
if (!searchable() && surface.search.back()) return
|
||||
props.onBack()
|
||||
}
|
||||
const backLabel = () =>
|
||||
!searchable() && surface.search.state.selected ? language.t("settings.backToSettings") : props.backLabel
|
||||
const current = () => props.groups.flatMap((group) => group.items).find((item) => item.value === props.value)
|
||||
const change = (value: string) => {
|
||||
surface.search.clear()
|
||||
props.onChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={change} class="settings">
|
||||
<div class="settings-mobile-nav">
|
||||
<button type="button" class="settings-back" onClick={back}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{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={change}>
|
||||
<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>
|
||||
<aside class="settings-sidebar" data-searchable={searchable()}>
|
||||
<div class="settings-nav">
|
||||
<button type="button" class="settings-back" onClick={back}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{backLabel()}</span>
|
||||
</button>
|
||||
<Show when={searchable()}>
|
||||
<SettingsSearch />
|
||||
</Show>
|
||||
<Show when={!searchable() || !surface.search.state.query.trim()}>
|
||||
<Tabs.List 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>
|
||||
</Tabs.List>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="settings-content">{props.children}</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const SettingsNotifications: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
<SettingsList>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IconProps } from "@opencode/ui/icon"
|
||||
import type { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { SettingsRootTab } from "./surface"
|
||||
|
||||
export const pageIcons = {
|
||||
general: "sliders",
|
||||
appearance: "appearance",
|
||||
notifications: "notifications",
|
||||
shortcuts: "keyboard",
|
||||
projects: "folder",
|
||||
workspaces: "outline-worktree",
|
||||
providers: "providers",
|
||||
models: "models",
|
||||
extensions: "extensions",
|
||||
servers: "server",
|
||||
experimental: "flask",
|
||||
about: "info",
|
||||
} as const satisfies Record<SettingsRootTab, IconProps["name"]>
|
||||
|
||||
export const pageLabels = {
|
||||
general: "settings.tab.preferences",
|
||||
appearance: "settings.general.section.appearance",
|
||||
notifications: "settings.tab.notifications",
|
||||
shortcuts: "settings.shortcuts.title",
|
||||
projects: "settings.tab.projects",
|
||||
workspaces: "settings.tab.workspaces",
|
||||
providers: "settings.providers.title",
|
||||
models: "settings.models.title",
|
||||
extensions: "settings.tab.extensions",
|
||||
servers: "settings.section.server",
|
||||
experimental: "settings.tab.experimental",
|
||||
about: "settings.tab.about",
|
||||
} as const satisfies Record<SettingsRootTab, Parameters<ReturnType<typeof useLanguage>["t"]>[0]>
|
||||
@@ -8,7 +8,8 @@ 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 { InlineServerSelect } from "@/settings/server-select"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import type { SettingsView } from "@/settings/surface"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
interface McpRowItem {
|
||||
@@ -20,7 +21,10 @@ interface PluginRowItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SettingsExtensions: Component = () => {
|
||||
export const SettingsExtensions: Component<{
|
||||
subtab?: SettingsView["subtab"]
|
||||
onSubtab: (value: SettingsView["subtab"]) => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
@@ -63,12 +67,18 @@ 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">
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={props.subtab ?? "mcps"}
|
||||
onChange={(value) => {
|
||||
if (value === "mcps" || value === "plugins" || value === "skills") props.onSubtab(value)
|
||||
}}
|
||||
class="settings-extensions-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>
|
||||
@@ -78,18 +88,20 @@ export const SettingsExtensions: Component = () => {
|
||||
<Tabs.Content value="mcps">
|
||||
<div class="settings-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
<span class="settings-extension-heading text-13-medium">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">
|
||||
{language.t("settings.extensions.manageConfig")}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<SettingsList variant="catalog">
|
||||
<For each={mcps()}>
|
||||
{(item) => (
|
||||
<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">
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||
<span class="settings-extension-name truncate">{item.name}</span>
|
||||
</div>
|
||||
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
||||
{item.name}
|
||||
@@ -97,58 +109,57 @@ export const SettingsExtensions: Component = () => {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="settings-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
<span class="settings-extension-heading text-13-medium">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">
|
||||
{language.t("settings.extensions.manageConfig")}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<SettingsList variant="catalog">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<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">
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||
<span class="settings-extension-name truncate">{plugin.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="skills">
|
||||
<div class="settings-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
<span class="settings-extension-heading text-13-medium">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<ExternalLink
|
||||
class="text-13-regular text-v2-text-accent hover:underline"
|
||||
href="https://opencode.ai/docs/skills/"
|
||||
>
|
||||
<ExternalLink class="settings-extension-link text-13-regular" href="https://opencode.ai/docs/skills/">
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
<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">
|
||||
<SettingsList variant="catalog">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<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">
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||
<span class="settings-extension-name truncate">{skill.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
|
||||
@@ -9,8 +9,6 @@ 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"
|
||||
|
||||
@@ -44,11 +42,7 @@ export const SettingsProviders: Component<{
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => (
|
||||
<SettingsServerScope directory={props.directory}>
|
||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
@@ -134,14 +128,13 @@ 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-providers">
|
||||
<div class="settings-tab-body settings-tab-body--sectioned settings-providers">
|
||||
<div class="settings-section" data-component="connected-providers-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsList variant="catalog">
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
|
||||
@@ -182,7 +175,7 @@ export const SettingsProviders: Component<{
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.providers.section.popular")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsList variant="catalog">
|
||||
<For each={popular()}>
|
||||
{(item) => (
|
||||
<div class="settings-provider-row">
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { SettingsProjectTab, SettingsRootTab, SettingsServerTab } from "./surface"
|
||||
|
||||
type Label = Parameters<ReturnType<typeof useLanguage>["t"]>[0]
|
||||
|
||||
// Metadata only: search must not mount settings pages or fetch their catalogs.
|
||||
type Entry<Tab> = {
|
||||
tab: Tab
|
||||
label: Label
|
||||
target?: string
|
||||
keywords?: string
|
||||
description?: Label
|
||||
section?: Label
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
available?: "desktop" | "browser" | "dev" | "mobile-dev"
|
||||
}
|
||||
|
||||
export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
{ tab: "general", label: "settings.tab.preferences" },
|
||||
{ tab: "appearance", label: "settings.general.section.appearance" },
|
||||
{ tab: "notifications", label: "settings.tab.notifications" },
|
||||
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
|
||||
{ tab: "experimental", label: "settings.tab.experimental" },
|
||||
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
|
||||
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.workspaces.default.title",
|
||||
target: "settings-workspace-destination",
|
||||
description: "settings.workspaces.default.description",
|
||||
keywords: "worktree workspace default destination",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "command.permissions.autoaccept.enable",
|
||||
target: "settings-auto-accept-permissions",
|
||||
keywords: "permissions approve allow auto accept",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.terminalPlacement.title",
|
||||
target: "settings-terminal-placement",
|
||||
keywords: "terminal side bottom position",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.followUpBehavior.title",
|
||||
target: "settings-follow-up-behavior",
|
||||
keywords: "queue steer follow up",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.pinchZoom.title",
|
||||
target: "settings-pinch-zoom",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "session.review.wrapLines",
|
||||
target: "settings-mobile-diff-wrap",
|
||||
keywords: "diff wrap lines",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.mobileTitlebarBottom.title",
|
||||
target: "settings-mobile-titlebar-bottom",
|
||||
available: "mobile-dev",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.timeline.detail",
|
||||
target: "settings-timeline-detail",
|
||||
section: "settings.timeline.title",
|
||||
keywords: "thinking reasoning tools timeline summary detailed",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.releaseNotes.title",
|
||||
target: "settings-release-notes",
|
||||
section: "settings.general.section.updates",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.updates.row.check.title",
|
||||
target: "settings-check-updates",
|
||||
section: "settings.general.section.updates",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.showCustomAgents.title",
|
||||
target: "settings-show-custom-agents",
|
||||
section: "settings.general.section.general",
|
||||
},
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.colorScheme.title",
|
||||
target: "settings-color-scheme",
|
||||
keywords: "dark mode light mode system",
|
||||
},
|
||||
{ tab: "appearance", label: "settings.general.row.theme.title", target: "settings-theme" },
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.uiFont.title",
|
||||
target: "settings-ui-font",
|
||||
keywords: "interface typeface",
|
||||
},
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.font.title",
|
||||
target: "settings-code-font",
|
||||
keywords: "code typeface",
|
||||
},
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.terminalFont.title",
|
||||
target: "settings-terminal-font",
|
||||
keywords: "terminal typeface",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.notifications.agent.title",
|
||||
target: "settings-notifications-agent",
|
||||
section: "settings.general.section.notifications",
|
||||
description: "settings.general.notifications.agent.description",
|
||||
keywords: "desktop notifications agent",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.notifications.permissions.title",
|
||||
target: "settings-notifications-permissions",
|
||||
section: "settings.general.section.notifications",
|
||||
description: "settings.general.notifications.permissions.description",
|
||||
keywords: "desktop notifications permissions",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.notifications.errors.title",
|
||||
target: "settings-notifications-errors",
|
||||
section: "settings.general.section.notifications",
|
||||
description: "settings.general.notifications.errors.description",
|
||||
keywords: "desktop notifications errors",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.sounds.agent.title",
|
||||
target: "settings-sounds-agent",
|
||||
section: "settings.general.section.sounds",
|
||||
description: "settings.general.sounds.agent.description",
|
||||
keywords: "sound audio agent",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.sounds.permissions.title",
|
||||
target: "settings-sounds-permissions",
|
||||
section: "settings.general.section.sounds",
|
||||
description: "settings.general.sounds.permissions.description",
|
||||
keywords: "sound audio permissions",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.sounds.errors.title",
|
||||
target: "settings-sounds-errors",
|
||||
section: "settings.general.section.sounds",
|
||||
description: "settings.general.sounds.errors.description",
|
||||
keywords: "sound audio errors",
|
||||
},
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.general.row.browserPane.title",
|
||||
target: "settings-experimental-browser",
|
||||
available: "browser",
|
||||
},
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.appearance.row.tabs.title",
|
||||
target: "settings-tab-layout",
|
||||
keywords: "vertical horizontal tabs",
|
||||
},
|
||||
{ tab: "experimental", label: "settings.appearance.row.projectName.title", target: "settings-show-project-name" },
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.general.row.showProjectIcon.title",
|
||||
target: "settings-show-project-icon",
|
||||
available: "dev",
|
||||
},
|
||||
]
|
||||
|
||||
export const serverSettings: Entry<SettingsServerTab>[] = [
|
||||
{ tab: "projects", label: "settings.tab.projects" },
|
||||
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
|
||||
{ tab: "providers", label: "settings.providers.title", keywords: "connect api key credentials" },
|
||||
{ tab: "models", label: "settings.models.title", keywords: "model picker visibility" },
|
||||
{ tab: "extensions", label: "settings.tab.extensions" },
|
||||
{
|
||||
tab: "extensions",
|
||||
subtab: "mcps",
|
||||
label: "settings.extensions.tab.mcps",
|
||||
keywords: "model context protocol tools",
|
||||
},
|
||||
{ tab: "extensions", subtab: "plugins", label: "status.popover.tab.plugins" },
|
||||
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.shell.title",
|
||||
target: "settings-shell",
|
||||
description: "settings.general.row.shell.description",
|
||||
keywords: "bash zsh powershell",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.server.preferences.websearch.title",
|
||||
target: "settings-websearch",
|
||||
description: "settings.server.preferences.websearch.description",
|
||||
keywords: "web search provider",
|
||||
},
|
||||
]
|
||||
|
||||
export const projectSettings: Entry<SettingsProjectTab>[] = [
|
||||
{ tab: "general", label: "project.settings.name.title", target: "settings-project-name", keywords: "rename" },
|
||||
{ tab: "general", label: "dialog.project.edit.icon", target: "settings-project-icon" },
|
||||
{ tab: "general", label: "dialog.project.edit.color", target: "settings-project-color" },
|
||||
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
|
||||
{ tab: "extensions", label: "settings.tab.extensions" },
|
||||
{
|
||||
tab: "extensions",
|
||||
subtab: "mcps",
|
||||
label: "settings.extensions.tab.mcps",
|
||||
keywords: "model context protocol tools",
|
||||
},
|
||||
{ tab: "extensions", subtab: "plugins", label: "status.popover.tab.plugins" },
|
||||
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
|
||||
{ tab: "extensions", subtab: "lsps", label: "project.settings.extensions.tab.lsps", keywords: "language servers" },
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createEffect, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function SettingsSearchEmpty(props: { query: string }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ query: props.query })
|
||||
let quoted: HTMLSpanElement | undefined
|
||||
let measure: HTMLSpanElement | undefined
|
||||
const text = (query: string) => language.t("settings.search.empty.query", { query })
|
||||
const update = () => {
|
||||
if (!quoted || !measure) return
|
||||
const width = quoted.getBoundingClientRect().width
|
||||
const fits = (query: string) => {
|
||||
measure!.textContent = text(query)
|
||||
return measure!.getBoundingClientRect().width <= width
|
||||
}
|
||||
if (fits(props.query)) {
|
||||
setState("query", props.query)
|
||||
return
|
||||
}
|
||||
|
||||
const characters = Array.from(
|
||||
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(props.query),
|
||||
(item) => item.segment,
|
||||
)
|
||||
let start = 0
|
||||
let end = characters.length
|
||||
while (start < end) {
|
||||
const middle = Math.ceil((start + end) / 2)
|
||||
if (fits(`${characters.slice(0, middle).join("").trimEnd()}…`)) {
|
||||
start = middle
|
||||
continue
|
||||
}
|
||||
end = middle - 1
|
||||
}
|
||||
setState("query", `${characters.slice(0, start).join("").trimEnd()}…`)
|
||||
}
|
||||
|
||||
createEffect(update)
|
||||
onMount(() => {
|
||||
if (!quoted || !measure) return
|
||||
// The measuring text also observes font changes that do not resize the available space.
|
||||
createResizeObserver([quoted, measure], update)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="settings-search-empty"
|
||||
role="status"
|
||||
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
|
||||
>
|
||||
{language.rich("settings.search.empty", {
|
||||
query: (
|
||||
<span ref={quoted} class="settings-search-empty-quoted">
|
||||
<bdi dir="auto">{text(state.query)}</bdi>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
<span class="settings-search-empty-measure" aria-hidden="true">
|
||||
<span ref={measure} dir="auto" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user